From f2b5f7006b73bbcf4cdeb61aa4acf1b2981a3708 Mon Sep 17 00:00:00 2001 From: DrMesta103 Date: Thu, 26 Mar 2026 11:49:21 +0300 Subject: [PATCH] update product --- .env.example | 23 +- 1.zip | Bin 0 -> 22 bytes README.md | 16 +- docker-compose.yml | 43 - docs/brands-api.md | 135 +++ docs/media-library-api.md | 150 +++ docs/products-api.md | 255 +++++ package-lock.json | 668 ++++++++++++- package.json | 6 + src/app.controller.ts | 2 + src/app.module.ts | 34 +- src/common/utils/json-transform.util.ts | 21 + src/config/configuration.ts | 9 + src/config/env.validation.ts | 56 ++ src/config/typeorm.config.ts | 48 +- src/main.ts | 10 + src/modules/auth/auth.controller.ts | 16 + src/modules/auth/auth.module.ts | 7 +- src/modules/auth/auth.service.ts | 177 +++- src/modules/auth/dto/login-password.dto.ts | 13 + src/modules/auth/dto/register-password.dto.ts | 22 + src/modules/auth/entities/auth-otp.entity.ts | 39 + .../auth/entities/user-session.entity.ts | 41 + src/modules/auth/sms.service.ts | 50 + .../catalog/admin-products.controller.ts | 136 +++ .../attribute-definitions.controller.ts | 54 ++ src/modules/catalog/brand.controller.ts | 87 ++ src/modules/catalog/brand.service.ts | 86 ++ src/modules/catalog/catalog.module.ts | 37 +- src/modules/catalog/category.controller.ts | 87 ++ src/modules/catalog/category.service.ts | 126 +++ .../catalog/dto/check-product-slug.dto.ts | 14 + .../dto/create-attribute-definition.dto.ts | 57 ++ src/modules/catalog/dto/create-brand.dto.ts | 34 + .../catalog/dto/create-category.dto.ts | 32 + .../catalog/dto/create-product-review.dto.ts | 39 + src/modules/catalog/dto/create-product.dto.ts | 161 ++++ .../catalog/dto/filter-product-reviews.dto.ts | 40 + .../catalog/dto/filter-products.dto.ts | 71 ++ .../dto/moderate-product-review.dto.ts | 21 + .../dto/product-attribute-input.dto.ts | 123 +++ src/modules/catalog/dto/product-meta.dto.ts | 45 + .../dto/update-attribute-definition.dto.ts | 6 + src/modules/catalog/dto/update-brand.dto.ts | 4 + .../catalog/dto/update-category.dto.ts | 4 + src/modules/catalog/dto/update-product.dto.ts | 4 + .../entities/attribute-definition.entity.ts | 64 ++ src/modules/catalog/entities/brand.entity.ts | 40 + .../catalog/entities/category.entity.ts | 18 + .../product-attribute-value.entity.ts | 51 + .../catalog/entities/product-meta.entity.ts | 41 + .../catalog/entities/product-review.entity.ts | 49 + .../catalog/entities/product.entity.ts | 113 ++- .../catalog/enums/attribute-data-type.enum.ts | 8 + .../catalog/enums/product-status.enum.ts | 5 + src/modules/catalog/products.controller.ts | 42 + src/modules/catalog/products.service.ts | 899 ++++++++++++++++++ .../media/dto/filter-media-assets.dto.ts | 35 + .../media/dto/update-media-asset.dto.ts | 4 + src/modules/media/dto/upload-media.dto.ts | 56 ++ .../media/entities/media-asset.entity.ts | 68 ++ src/modules/media/enums/media-section.enum.ts | 8 + src/modules/media/media.controller.ts | 83 ++ src/modules/media/media.module.ts | 14 + src/modules/media/media.service.ts | 176 ++++ src/modules/storage/storage.module.ts | 9 + src/modules/storage/storage.service.ts | 160 ++++ .../users/entities/loyalty-profile.entity.ts | 53 ++ .../entities/user-level-history.entity.ts | 33 + src/modules/users/entities/user.entity.ts | 46 +- .../entities/wallet-transaction.entity.ts | 39 + src/modules/users/entities/wallet.entity.ts | 44 + src/modules/users/users.module.ts | 14 +- src/modules/users/users.service.ts | 62 +- tmp-start.err | 35 + tmp-start.out | 13 + 76 files changed, 5252 insertions(+), 139 deletions(-) create mode 100644 1.zip delete mode 100644 docker-compose.yml create mode 100644 docs/brands-api.md create mode 100644 docs/media-library-api.md create mode 100644 docs/products-api.md create mode 100644 src/common/utils/json-transform.util.ts create mode 100644 src/modules/auth/dto/login-password.dto.ts create mode 100644 src/modules/auth/dto/register-password.dto.ts create mode 100644 src/modules/auth/entities/auth-otp.entity.ts create mode 100644 src/modules/auth/entities/user-session.entity.ts create mode 100644 src/modules/auth/sms.service.ts create mode 100644 src/modules/catalog/admin-products.controller.ts create mode 100644 src/modules/catalog/attribute-definitions.controller.ts create mode 100644 src/modules/catalog/brand.controller.ts create mode 100644 src/modules/catalog/brand.service.ts create mode 100644 src/modules/catalog/category.controller.ts create mode 100644 src/modules/catalog/category.service.ts create mode 100644 src/modules/catalog/dto/check-product-slug.dto.ts create mode 100644 src/modules/catalog/dto/create-attribute-definition.dto.ts create mode 100644 src/modules/catalog/dto/create-brand.dto.ts create mode 100644 src/modules/catalog/dto/create-category.dto.ts create mode 100644 src/modules/catalog/dto/create-product-review.dto.ts create mode 100644 src/modules/catalog/dto/create-product.dto.ts create mode 100644 src/modules/catalog/dto/filter-product-reviews.dto.ts create mode 100644 src/modules/catalog/dto/filter-products.dto.ts create mode 100644 src/modules/catalog/dto/moderate-product-review.dto.ts create mode 100644 src/modules/catalog/dto/product-attribute-input.dto.ts create mode 100644 src/modules/catalog/dto/product-meta.dto.ts create mode 100644 src/modules/catalog/dto/update-attribute-definition.dto.ts create mode 100644 src/modules/catalog/dto/update-brand.dto.ts create mode 100644 src/modules/catalog/dto/update-category.dto.ts create mode 100644 src/modules/catalog/dto/update-product.dto.ts create mode 100644 src/modules/catalog/entities/attribute-definition.entity.ts create mode 100644 src/modules/catalog/entities/brand.entity.ts create mode 100644 src/modules/catalog/entities/product-attribute-value.entity.ts create mode 100644 src/modules/catalog/entities/product-meta.entity.ts create mode 100644 src/modules/catalog/entities/product-review.entity.ts create mode 100644 src/modules/catalog/enums/attribute-data-type.enum.ts create mode 100644 src/modules/catalog/enums/product-status.enum.ts create mode 100644 src/modules/catalog/products.controller.ts create mode 100644 src/modules/catalog/products.service.ts create mode 100644 src/modules/media/dto/filter-media-assets.dto.ts create mode 100644 src/modules/media/dto/update-media-asset.dto.ts create mode 100644 src/modules/media/dto/upload-media.dto.ts create mode 100644 src/modules/media/entities/media-asset.entity.ts create mode 100644 src/modules/media/enums/media-section.enum.ts create mode 100644 src/modules/media/media.controller.ts create mode 100644 src/modules/media/media.module.ts create mode 100644 src/modules/media/media.service.ts create mode 100644 src/modules/storage/storage.module.ts create mode 100644 src/modules/storage/storage.service.ts create mode 100644 src/modules/users/entities/loyalty-profile.entity.ts create mode 100644 src/modules/users/entities/user-level-history.entity.ts create mode 100644 src/modules/users/entities/wallet-transaction.entity.ts create mode 100644 src/modules/users/entities/wallet.entity.ts create mode 100644 tmp-start.err create mode 100644 tmp-start.out diff --git a/.env.example b/.env.example index ec6780b1..30bba1e0 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,23 @@ PORT=3000 NODE_ENV=development -DB_URL=postgres://parsdbshop:ZtKKAQWA00umtkNXUMcjVNRD6avXFOVDOfqGcTTLwhnGUYq6EnSvaYsyJi06sx6j@62.3.14.124:6986/postgres -REDIS_URL=redis://parsuserdb:xTpObuam6vTAAtWhn92rvQdo8rjhO22K4IxyJxdooUAPoyY9zLbYSYBSRm6io7E6@62.3.14.124:6801/0 -MINIO_ENDPOINT=s3.ir-thr-at1.arvanstorage.ir +DB_URL=postgres://postgres:postgres@localhost:5432/parsshop +DB_SSL=false +REDIS_URL=redis://localhost:6379 +MINIO_ENDPOINT=localhost MINIO_PORT=9000 -MINIO_ACCESS_KEY=8e66af66-67cb-4dcb-ba62-36e88ad7083e -MINIO_SECRET_KEY=770b6bd2f4a93313312dd29bdee80fd57b1490ec86039124b44333a8f150d138 -MINIO_BUCKET=pod -JWT_SECRET=HJAKINMAqi1732bJHGHABADRMESTAhad +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=parsshop +MINIO_PUBLIC_BUCKET=parsshop-public +MINIO_PRIVATE_BUCKET=parsshop-private +MINIO_PUBLIC_URL=http://localhost:9000 +JWT_SECRET=change-me JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=30d SMS_API_KEY=replace-me +SMS_WSDL_URL=http://payammatni.com/webservice/send.php?wsdl +SMS_USERNAME=engel5960 +SMS_PASSWORD=replace-me +SMS_NUMBER=80008 OTP_TTL_SECONDS=120 diff --git a/1.zip b/1.zip new file mode 100644 index 0000000000000000000000000000000000000000..15cb0ecb3e219d1701294bfdf0fe3f5cb5d208e7 GIT binary patch literal 22 NcmWIWW@Tf*000g10H*)| literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 22b689d1..cd204d07 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ Phase 1 bootstrap for a NestJS backend focused on bearings e-commerce. ## Included - PostgreSQL + TypeORM -- Docker Compose for PostgreSQL, Redis, and MinIO - Global validation pipe - Standard API response interceptor - Core entities: User, Product, Category @@ -15,12 +14,7 @@ Phase 1 bootstrap for a NestJS backend focused on bearings e-commerce. ## Quick Start 1. Copy `.env.example` to `.env` -2. Start infrastructure: - -```bash -docker compose up -d -``` - +2. Make sure your PostgreSQL service is running and matches `DB_URL` 3. Install dependencies: ```bash @@ -30,5 +24,11 @@ npm install 4. Run the app: ```bash -npm run start:dev +npm start +``` + +5. Open Swagger: + +```bash +http://localhost:3000/docs ``` diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index e0bce6e5..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,43 +0,0 @@ -version: "3.9" - -services: - postgres: - image: postgres:16-alpine - container_name: parsshop-postgres - restart: unless-stopped - environment: - POSTGRES_DB: parsshop - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - container_name: parsshop-redis - restart: unless-stopped - ports: - - "6379:6379" - volumes: - - redis_data:/data - - minio: - image: minio/minio:latest - container_name: parsshop-minio - restart: unless-stopped - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin - ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio_data:/data - -volumes: - postgres_data: - redis_data: - minio_data: diff --git a/docs/brands-api.md b/docs/brands-api.md new file mode 100644 index 00000000..d77c466d --- /dev/null +++ b/docs/brands-api.md @@ -0,0 +1,135 @@ +# Brands API + +Base URL: `/api` + +All responses follow the standard wrapper: + +```json +{ + "success": true, + "statusCode": 200, + "path": "/api/brands", + "timestamp": "2026-03-26T10:00:00.000Z", + "data": {} +} +``` + +## Brand Model + +- `id` +- `name` +- `slug` +- `imageUrl` +- `type` +- `createdAt` +- `updatedAt` + +## Public Brand APIs + +### `GET /api/brands` + +Returns brand list for storefront and admin forms. + +### `GET /api/brands/:id` + +Returns one brand. + +## Admin Brand APIs + +These endpoints require: + +- Bearer token +- Admin role +- `brands.manage` permission + +### `POST /api/brands` + +Content type: `multipart/form-data` + +Fields: + +- `name` +- `slug` +- `type` +- `existingImageUrl` +- `image` file + +Behavior: + +- if `image` is uploaded, backend stores it and uses the uploaded URL +- if `existingImageUrl` is sent, backend uses that existing media URL + +### `PATCH /api/brands/:id` + +Content type: `multipart/form-data` + +Fields: + +- `name` +- `slug` +- `type` +- `existingImageUrl` +- `image` file + +Behavior: + +- if `image` is uploaded, old image is replaced +- if `existingImageUrl` is sent, selected existing media URL is used +- if `existingImageUrl` is empty, image is cleared + +### `DELETE /api/brands/:id` + +Deletes brand and its image. + +## Product API Changes + +Product create and update now support brand selection by `brandId`. + +### Product create/update fields related to brand + +- `brandId` optional +- `brand` optional fallback text + +Recommended frontend behavior: + +1. Load brands from `GET /api/brands` +2. Let admin select one brand +3. Send `brandId` +4. Do not rely on manual `brand` text unless you explicitly want a fallback + +## Product list filters + +`GET /api/admin/products` and `GET /api/products` now support: + +- `brandId` +- `brand` + +`brandId` is the preferred filter. + +## Product response + +Product responses now include: + +- `brand` +- `brandInfo` + +`brandInfo` shape: + +```json +{ + "id": "uuid", + "name": "SKF", + "slug": "skf", + "imageUrl": "https://cdn.example.com/brands/skf.jpg", + "type": "bearing" +} +``` + +## Frontend Integration Notes + +- Use brand image in admin selects/cards where useful +- For add/edit product page, load brands first +- Filter brands by product `type` on frontend if needed +- Use the same media picker flow as categories: + - either upload a new image + - or pick an existing media URL and send `existingImageUrl` diff --git a/docs/media-library-api.md b/docs/media-library-api.md new file mode 100644 index 00000000..88863db2 --- /dev/null +++ b/docs/media-library-api.md @@ -0,0 +1,150 @@ +# Media Library API + +Base URL: `/api` + +All media endpoints are admin-only and require: + +- Bearer token +- Admin role +- `media.manage` permission + +## Library Sections + +- `image` +- `gallery` +- `audio` +- `video` +- `model3d` +- `document` + +## Media Asset Model + +- `id` +- `section` +- `folder` +- `originalName` +- `objectName` +- `url` +- `bucket` +- `mimeType` +- `extension` +- `size` +- `title` +- `alt` +- `caption` +- `metadata` +- `isPublic` +- `createdAt` +- `updatedAt` + +## Endpoints + +### `GET /api/admin/media/overview` + +Returns grouped counts by `section` and `folder`. + +### `GET /api/admin/media` + +Query params: + +- `section` +- `folder` +- `search` +- `page` +- `limit` + +Use this endpoint to power a WordPress-like media browser. + +### `POST /api/admin/media/upload` + +Content type: `multipart/form-data` + +Fields: + +- `files` one or many files +- `section` optional +- `folder` optional, default `root` +- `title` optional +- `alt` optional +- `caption` optional +- `metadata` optional JSON string +- `isPublic` optional + +Behavior: + +- if `section` is sent, file is stored in that section +- if `section` is omitted, backend infers it from mime type / extension +- files are stored in object storage under `media/
//...` + +### `GET /api/admin/media/:id` + +Returns one media asset. + +### `PATCH /api/admin/media/:id` + +Updates metadata: + +- `section` +- `folder` +- `title` +- `alt` +- `caption` +- `metadata` +- `isPublic` + +### `DELETE /api/admin/media/:id` + +Deletes DB record and object-storage file. + +## Category Image + +Category now supports: + +- `imageUrl` + +### `POST /api/categories` + +Admin-only for create. + +Content type: `multipart/form-data` + +Fields: + +- `name` +- `slug` +- `type` +- `parentId` +- `existingImageUrl` +- `image` file + +### `PATCH /api/categories/:id` + +Admin-only for update. + +Content type: `multipart/form-data` + +Fields: + +- `name` +- `slug` +- `type` +- `parentId` +- `existingImageUrl` +- `image` file + +Rules: + +- if `image` is uploaded, it replaces the old image +- if `existingImageUrl` is sent, category uses that media URL +- if `existingImageUrl` is sent as empty value, category image is cleared + +## Integration Pattern + +Recommended admin flow: + +1. Open media library with `GET /api/admin/media` +2. User either selects an existing asset or uploads a new one with `POST /api/admin/media/upload` +3. Frontend takes selected asset `url` +4. That `url` is sent into product/category create-update payload as existing media URL + +This keeps product and category APIs simple while still supporting a professional reusable media library. diff --git a/docs/products-api.md b/docs/products-api.md new file mode 100644 index 00000000..3049e51b --- /dev/null +++ b/docs/products-api.md @@ -0,0 +1,255 @@ +# Products API + +Base URL: `/api` + +All responses are wrapped by the global response interceptor: + +```json +{ + "success": true, + "statusCode": 200, + "path": "/api/products", + "timestamp": "2026-03-25T10:00:00.000Z", + "data": {} +} +``` + +## Product Model + +Each product now supports: + +- `sku` +- `title` +- `slug` +- `summary` +- `description` +- `technicalCode` +- `brand` +- `basePriceUSD` +- `salePriceUSD` +- `stock` +- `featured` +- `type` +- `status`: `draft | published | archived` +- `mainImageUrl` +- `imageGalleryUrls` +- `threeDModelUrl` +- `attributes`: JSON object for technical specs/features +- `tags`: string array +- `averageRating` +- `reviewsCount` +- `category` + +## Public Product APIs + +### `GET /api/products` + +Lists published products for storefront. + +Query params: + +- `search` +- `type` +- `categoryId` +- `brand` +- `attributes` as JSON string +- `tags` as JSON string array +- `featured` +- `page` +- `limit` + +Example: + +```http +GET /api/products?search=skf&type=bearing&featured=true&page=1&limit=20 +``` + +### `GET /api/products/:id` + +Returns one published product with category and up to 10 approved reviews. + +### `GET /api/products/:id/reviews` + +Returns approved reviews for one published product. + +### `POST /api/products/:id/reviews` + +Creates a review for a published product. New reviews are stored as pending approval. + +Request body: + +```json +{ + "name": "Ali Rezaei", + "email": "ali@example.com", + "rating": 5, + "title": "Excellent quality", + "comment": "Good quality product with solid packaging." +} +``` + +## Admin Product APIs + +These endpoints require: + +- Bearer token +- Admin role +- `products.manage` permission + +### `GET /api/admin/products` + +Lists all products for admin panel, including drafts and archived items. + +Query params: + +- `search` +- `type` +- `status` +- `categoryId` +- `brand` +- `attributes` as JSON string +- `tags` as JSON string array +- `featured` +- `page` +- `limit` + +### `GET /api/admin/products/:id` + +Returns one product for admin panel. + +### `POST /api/admin/products` + +Creates a product. Content type: `multipart/form-data` + +Fields: + +- `sku` +- `title` +- `slug` +- `summary` +- `description` +- `technicalCode` +- `brand` +- `basePriceUSD` +- `salePriceUSD` +- `stock` +- `featured` +- `type` +- `status` +- `categoryId` +- `attributes` as JSON string +- `tags` as JSON string array +- `existingMainImageUrl` +- `existingGalleryUrls` as JSON string array +- `existingThreeDModelUrl` +- `mainImage` file +- `images` files +- `model3d` file + +Notes: + +- `mainImage` is the primary product image. +- `images` fills the gallery. +- `model3d` is for `.glb`, `.gltf`, or other supported 3D assets. +- If no new file is uploaded, existing URLs can be sent. + +Example multipart fields: + +```text +sku=BRG-6006-2RS +title=SKF 6006-2RS Deep Groove Bearing +slug=skf-6006-2rs +summary=Industrial bearing for motors and gearboxes +description=Full product description +technicalCode=SKF-6006 +brand=SKF +basePriceUSD=42.5 +salePriceUSD=39.99 +stock=28 +featured=true +type=bearing +status=published +categoryId= +attributes={"innerDiameter":"30mm","outerDiameter":"55mm","sealType":"2RS"} +tags=["bearing","skf","industrial"] +``` + +### `PATCH /api/admin/products/:id` + +Updates a product. Content type: `multipart/form-data` + +Behavior: + +- upload new `mainImage` to replace the old main image +- upload new `images` to replace the whole gallery +- upload new `model3d` to replace the old 3D model +- send `existingGalleryUrls` to keep only selected old gallery items +- send empty `existingMainImageUrl` or `existingThreeDModelUrl` to clear them + +### `DELETE /api/admin/products/:id` + +Deletes product row and linked uploaded assets. + +## Admin Review APIs + +### `GET /api/admin/products/reviews/list` + +Lists reviews for moderation. + +Query params: + +- `productId` +- `isApproved` +- `isPinned` +- `page` +- `limit` + +### `PATCH /api/admin/products/reviews/:reviewId` + +Approves or pins a review. + +Request body: + +```json +{ + "isApproved": true, + "isPinned": false +} +``` + +When approval state changes, `averageRating` and `reviewsCount` on the product are recalculated. + +### `DELETE /api/admin/products/reviews/:reviewId` + +Deletes a review and refreshes product review stats. + +## Category APIs + +Current category endpoints: + +- `POST /api/categories` +- `GET /api/categories` +- `GET /api/categories/:id` +- `PATCH /api/categories/:id` +- `DELETE /api/categories/:id` + +Category object: + +- `id` +- `name` +- `slug` +- `type` +- `parent` +- `children` + +Rule: + +- child category type must match parent category type + +## Frontend Integration Notes + +- Public storefront should use `/api/products` +- Admin panel should use `/api/admin/products` +- Reviews submitted from storefront are pending until approved in admin +- `attributes` and `tags` should be managed as structured JSON on the frontend +- Use `mainImageUrl`, `imageGalleryUrls`, and `threeDModelUrl` as separate media sections in UI diff --git a/package-lock.json b/package-lock.json index 5d2d5341..f824a56b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,16 +15,21 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "iterare": "1.2.1", + "minio": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.13.1", "redis": "^5.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "soap": "^1.1.11", + "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.20" }, "devDependencies": { @@ -33,6 +38,7 @@ "@nestjs/testing": "^11.0.0", "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", + "@types/multer": "^2.0.0", "@types/node": "^22.10.1", "@types/passport-jwt": "^4.0.1", "eslint": "^9.18.0", @@ -990,6 +996,12 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://mirror-npm.runflare.com/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "11.0.16", "resolved": "https://mirror-npm.runflare.com/@nestjs/cli/-/cli-11.0.16.tgz", @@ -1135,6 +1147,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.0", + "resolved": "https://mirror-npm.runflare.com/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", + "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "11.0.5", "resolved": "https://mirror-npm.runflare.com/@nestjs/passport/-/passport-11.0.5.tgz", @@ -1240,6 +1272,39 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/swagger": { + "version": "11.2.6", + "resolved": "https://mirror-npm.runflare.com/@nestjs/swagger/-/swagger-11.2.6.tgz", + "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.0", + "js-yaml": "4.1.1", + "lodash": "4.17.23", + "path-to-regexp": "8.3.0", + "swagger-ui-dist": "5.31.0" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "11.1.17", "resolved": "https://mirror-npm.runflare.com/@nestjs/testing/-/testing-11.1.17.tgz", @@ -1281,6 +1346,18 @@ "typeorm": "^0.3.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://mirror-npm.runflare.com/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nuxt/opencollective": { "version": "0.4.1", "resolved": "https://mirror-npm.runflare.com/@nuxt/opencollective/-/opencollective-0.4.1.tgz", @@ -1297,6 +1374,15 @@ "npm": ">=5.10.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://mirror-npm.runflare.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://mirror-npm.runflare.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1388,6 +1474,13 @@ "@redis/client": "^5.11.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://mirror-npm.runflare.com/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://mirror-npm.runflare.com/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -1560,6 +1653,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://mirror-npm.runflare.com/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://mirror-npm.runflare.com/@types/node/-/node-22.19.15.tgz", @@ -1803,6 +1906,24 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://mirror-npm.runflare.com/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://mirror-npm.runflare.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://mirror-npm.runflare.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -2034,7 +2155,6 @@ "version": "2.0.1", "resolved": "https://mirror-npm.runflare.com/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -2044,6 +2164,24 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://mirror-npm.runflare.com/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://mirror-npm.runflare.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://mirror-npm.runflare.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://mirror-npm.runflare.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2059,6 +2197,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://mirror-npm.runflare.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-ntlm": { + "version": "1.4.6", + "resolved": "https://mirror-npm.runflare.com/axios-ntlm/-/axios-ntlm-1.4.6.tgz", + "integrity": "sha512-4nR5cbVEBfPMTFkd77FEDpDuaR205JKibmrkaQyNwGcCx0szWNpRZaL0jZyMx4+mVY2PXHjRHuJafv9Oipl0Kg==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.2", + "des.js": "^1.1.0", + "dev-null": "^0.1.1", + "js-md4": "^0.3.2" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://mirror-npm.runflare.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2124,6 +2285,15 @@ "readable-stream": "^3.4.0" } }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://mirror-npm.runflare.com/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://mirror-npm.runflare.com/body-parser/-/body-parser-2.2.2.tgz", @@ -2158,6 +2328,12 @@ "concat-map": "0.0.1" } }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://mirror-npm.runflare.com/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://mirror-npm.runflare.com/browserslist/-/browserslist-4.28.1.tgz", @@ -2217,6 +2393,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://mirror-npm.runflare.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://mirror-npm.runflare.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2307,9 +2492,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001780", - "resolved": "https://mirror-npm.runflare.com/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", - "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "version": "1.0.30001781", + "resolved": "https://mirror-npm.runflare.com/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", "dev": true, "funding": [ { @@ -2532,6 +2717,18 @@ "color-support": "bin.js" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://mirror-npm.runflare.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://mirror-npm.runflare.com/commander/-/commander-4.1.1.tgz", @@ -2728,6 +2925,15 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://mirror-npm.runflare.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://mirror-npm.runflare.com/dedent/-/dedent-1.7.2.tgz", @@ -2789,6 +2995,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://mirror-npm.runflare.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://mirror-npm.runflare.com/delegates/-/delegates-1.0.0.tgz", @@ -2804,6 +3019,16 @@ "node": ">= 0.8" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://mirror-npm.runflare.com/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://mirror-npm.runflare.com/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2813,6 +3038,22 @@ "node": ">=8" } }, + "node_modules/dev-null": { + "version": "0.1.1", + "resolved": "https://mirror-npm.runflare.com/dev-null/-/dev-null-0.1.1.tgz", + "integrity": "sha512-nMNZG0zfMgmdv8S5O0TM5cpwNbGKRGPCxVsr0SmA3NZZy9CYBbuNLL0PD3Acx9e5LIUgwONXtM9kM6RlawPxEQ==", + "license": "MIT" + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://mirror-npm.runflare.com/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://mirror-npm.runflare.com/diff/-/diff-4.0.4.tgz", @@ -2980,6 +3221,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://mirror-npm.runflare.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://mirror-npm.runflare.com/escalade/-/escalade-3.2.0.tgz", @@ -3256,6 +3512,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://mirror-npm.runflare.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://mirror-npm.runflare.com/events/-/events-3.3.0.tgz", @@ -3360,6 +3622,41 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://mirror-npm.runflare.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://mirror-npm.runflare.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://mirror-npm.runflare.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3391,6 +3688,15 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://mirror-npm.runflare.com/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://mirror-npm.runflare.com/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3450,6 +3756,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://mirror-npm.runflare.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://mirror-npm.runflare.com/for-each/-/for-each-0.3.5.tgz", @@ -3509,6 +3835,60 @@ "webpack": "^5.11.0" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://mirror-npm.runflare.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://mirror-npm.runflare.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://mirror-npm.runflare.com/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/formidable": { + "version": "3.5.4", + "resolved": "https://mirror-npm.runflare.com/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://mirror-npm.runflare.com/forwarded/-/forwarded-0.2.0.tgz", @@ -3961,12 +4341,12 @@ "license": "ISC" }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://mirror-npm.runflare.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.3.0", + "resolved": "https://mirror-npm.runflare.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-arrayish": { @@ -4131,6 +4511,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://mirror-npm.runflare.com/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://mirror-npm.runflare.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4142,7 +4528,6 @@ "version": "4.1.1", "resolved": "https://mirror-npm.runflare.com/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4549,6 +4934,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://mirror-npm.runflare.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://mirror-npm.runflare.com/minimatch/-/minimatch-3.1.5.tgz", @@ -4571,6 +4962,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minio": { + "version": "8.0.7", + "resolved": "https://mirror-npm.runflare.com/minio/-/minio-8.0.7.tgz", + "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^5.3.4", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://mirror-npm.runflare.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minio/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://mirror-npm.runflare.com/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/minipass": { "version": "7.1.3", "resolved": "https://mirror-npm.runflare.com/minipass/-/minipass-7.1.3.tgz", @@ -5021,6 +5457,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://mirror-npm.runflare.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://mirror-npm.runflare.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5300,6 +5751,21 @@ "node": ">= 0.10" } }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://mirror-npm.runflare.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://mirror-npm.runflare.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://mirror-npm.runflare.com/punycode/-/punycode-2.3.1.tgz", @@ -5325,6 +5791,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://mirror-npm.runflare.com/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://mirror-npm.runflare.com/range-parser/-/range-parser-1.2.1.tgz", @@ -5537,6 +6021,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://mirror-npm.runflare.com/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://mirror-npm.runflare.com/schema-utils/-/schema-utils-3.3.0.tgz", @@ -5801,6 +6294,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/soap": { + "version": "1.8.0", + "resolved": "https://mirror-npm.runflare.com/soap/-/soap-1.8.0.tgz", + "integrity": "sha512-WRIzZm4M13a9j1t8yMdZZtbbkxNatXAhvtO8UXc/LvdfZ/Op1MqZS6qsAbILLsLTk3oLM/PRw0XOG0U53dAZzg==", + "license": "MIT", + "dependencies": { + "axios": "^1.13.6", + "axios-ntlm": "^1.4.6", + "debug": "^4.4.3", + "follow-redirects": "^1.15.11", + "formidable": "^3.5.4", + "sax": "^1.5.0", + "whatwg-mimetype": "4.0.0", + "xml-crypto": "^6.1.2" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://mirror-npm.runflare.com/source-map/-/source-map-0.7.4.tgz", @@ -5832,6 +6344,15 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://mirror-npm.runflare.com/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://mirror-npm.runflare.com/split2/-/split2-4.2.0.tgz", @@ -5866,6 +6387,21 @@ "node": ">= 0.8" } }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://mirror-npm.runflare.com/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://mirror-npm.runflare.com/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://mirror-npm.runflare.com/streamsearch/-/streamsearch-1.1.0.tgz", @@ -5874,6 +6410,15 @@ "node": ">=10.0.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://mirror-npm.runflare.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://mirror-npm.runflare.com/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5960,6 +6505,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://mirror-npm.runflare.com/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://mirror-npm.runflare.com/strtok3/-/strtok3-10.3.5.tgz", @@ -5989,6 +6546,30 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://mirror-npm.runflare.com/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://mirror-npm.runflare.com/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://mirror-npm.runflare.com/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -6016,9 +6597,9 @@ } }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://mirror-npm.runflare.com/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.2", + "resolved": "https://mirror-npm.runflare.com/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "dev": true, "license": "MIT", "engines": { @@ -6165,6 +6746,15 @@ "dev": true, "license": "MIT" }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://mirror-npm.runflare.com/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://mirror-npm.runflare.com/to-buffer/-/to-buffer-1.2.2.tgz", @@ -6886,6 +7476,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://mirror-npm.runflare.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://mirror-npm.runflare.com/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -6990,6 +7589,51 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://mirror-npm.runflare.com/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://mirror-npm.runflare.com/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://mirror-npm.runflare.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://mirror-npm.runflare.com/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://mirror-npm.runflare.com/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 337214ea..00ef2ede 100644 --- a/package.json +++ b/package.json @@ -21,16 +21,21 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "iterare": "1.2.1", + "minio": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.13.1", "redis": "^5.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "soap": "^1.1.11", + "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.20" }, "devDependencies": { @@ -39,6 +44,7 @@ "@nestjs/testing": "^11.0.0", "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", + "@types/multer": "^2.0.0", "@types/node": "^22.10.1", "@types/passport-jwt": "^4.0.1", "eslint": "^9.18.0", diff --git a/src/app.controller.ts b/src/app.controller.ts index 4fd65212..ad91139c 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { AppService } from './app.service'; +@ApiTags('Health') @Controller() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/src/app.module.ts b/src/app.module.ts index 7f3f17d0..5d40a7fa 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,10 +7,24 @@ import configuration from './config/configuration'; import { validateEnv } from './config/env.validation'; import { typeOrmConfigFactory } from './config/typeorm.config'; import { AuthModule } from './modules/auth/auth.module'; +import { AuthOtp } from './modules/auth/entities/auth-otp.entity'; +import { UserSession } from './modules/auth/entities/user-session.entity'; import { Category } from './modules/catalog/entities/category.entity'; +import { AttributeDefinition } from './modules/catalog/entities/attribute-definition.entity'; +import { Brand } from './modules/catalog/entities/brand.entity'; +import { ProductAttributeValue } from './modules/catalog/entities/product-attribute-value.entity'; +import { ProductMeta } from './modules/catalog/entities/product-meta.entity'; import { Product } from './modules/catalog/entities/product.entity'; +import { ProductReview } from './modules/catalog/entities/product-review.entity'; +import { MediaModule } from './modules/media/media.module'; +import { MediaAsset } from './modules/media/entities/media-asset.entity'; import { CatalogModule } from './modules/catalog/catalog.module'; +import { StorageModule } from './modules/storage/storage.module'; +import { LoyaltyProfile } from './modules/users/entities/loyalty-profile.entity'; import { User } from './modules/users/entities/user.entity'; +import { UserLevelHistory } from './modules/users/entities/user-level-history.entity'; +import { WalletTransaction } from './modules/users/entities/wallet-transaction.entity'; +import { Wallet } from './modules/users/entities/wallet.entity'; import { UsersModule } from './modules/users/users.module'; @Module({ @@ -22,9 +36,27 @@ import { UsersModule } from './modules/users/users.module'; envFilePath: ['.env'], }), TypeOrmModule.forRootAsync(typeOrmConfigFactory), - TypeOrmModule.forFeature([User, Product, Category]), + TypeOrmModule.forFeature([ + User, + Wallet, + WalletTransaction, + LoyaltyProfile, + UserLevelHistory, + AuthOtp, + UserSession, + Product, + Category, + Brand, + ProductReview, + ProductMeta, + AttributeDefinition, + ProductAttributeValue, + MediaAsset, + ]), + StorageModule, UsersModule, CatalogModule, + MediaModule, AuthModule, ], controllers: [AppController], diff --git a/src/common/utils/json-transform.util.ts b/src/common/utils/json-transform.util.ts new file mode 100644 index 00000000..1ee71c63 --- /dev/null +++ b/src/common/utils/json-transform.util.ts @@ -0,0 +1,21 @@ +import { TransformFnParams } from 'class-transformer'; + +export function parseJsonValue({ value }: TransformFnParams) { + if (value === undefined || value === null || value === '') { + return undefined; + } + + if (typeof value === 'object') { + return value; + } + + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return value; + } + } + + return value; +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 6c6106e8..444b558e 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -5,6 +5,7 @@ export default () => ({ }, database: { url: process.env.DB_URL, + ssl: (process.env.DB_SSL ?? 'false') === 'true', }, redis: { url: process.env.REDIS_URL, @@ -16,6 +17,10 @@ export default () => ({ }, sms: { apiKey: process.env.SMS_API_KEY, + wsdlUrl: process.env.SMS_WSDL_URL, + username: process.env.SMS_USERNAME, + password: process.env.SMS_PASSWORD, + fromNumber: process.env.SMS_NUMBER, }, otp: { ttlSeconds: parseInt(process.env.OTP_TTL_SECONDS ?? '120', 10), @@ -23,8 +28,12 @@ export default () => ({ minio: { endpoint: process.env.MINIO_ENDPOINT, port: parseInt(process.env.MINIO_PORT ?? '9000', 10), + useSsl: (process.env.MINIO_USE_SSL ?? 'false') === 'true', accessKey: process.env.MINIO_ACCESS_KEY, secretKey: process.env.MINIO_SECRET_KEY, bucket: process.env.MINIO_BUCKET, + publicBucket: process.env.MINIO_PUBLIC_BUCKET ?? process.env.MINIO_BUCKET, + privateBucket: process.env.MINIO_PRIVATE_BUCKET ?? 'parsshop-private', + publicUrl: process.env.MINIO_PUBLIC_URL, }, }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index adde0704..35b30e11 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -14,6 +14,10 @@ class EnvironmentVariables { @IsString() DB_URL!: string; + @IsOptional() + @IsString() + DB_SSL?: string; + @IsOptional() @IsString() REDIS_URL?: string; @@ -34,9 +38,61 @@ class EnvironmentVariables { @IsString() SMS_API_KEY!: string; + @IsOptional() + @IsString() + SMS_WSDL_URL?: string; + + @IsOptional() + @IsString() + SMS_USERNAME?: string; + + @IsOptional() + @IsString() + SMS_PASSWORD?: string; + + @IsOptional() + @IsString() + SMS_NUMBER?: string; + @IsOptional() @IsNumberString() OTP_TTL_SECONDS?: string; + + @IsOptional() + @IsString() + MINIO_ENDPOINT?: string; + + @IsOptional() + @IsNumberString() + MINIO_PORT?: string; + + @IsOptional() + @IsString() + MINIO_USE_SSL?: string; + + @IsOptional() + @IsString() + MINIO_ACCESS_KEY?: string; + + @IsOptional() + @IsString() + MINIO_SECRET_KEY?: string; + + @IsOptional() + @IsString() + MINIO_BUCKET?: string; + + @IsOptional() + @IsString() + MINIO_PUBLIC_BUCKET?: string; + + @IsOptional() + @IsString() + MINIO_PRIVATE_BUCKET?: string; + + @IsOptional() + @IsString() + MINIO_PUBLIC_URL?: string; } export function validateEnv(config: Record) { diff --git a/src/config/typeorm.config.ts b/src/config/typeorm.config.ts index e2eb18f3..6784d5c1 100644 --- a/src/config/typeorm.config.ts +++ b/src/config/typeorm.config.ts @@ -1,18 +1,52 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModuleAsyncOptions, TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { AuthOtp } from '../modules/auth/entities/auth-otp.entity'; +import { UserSession } from '../modules/auth/entities/user-session.entity'; +import { AttributeDefinition } from '../modules/catalog/entities/attribute-definition.entity'; +import { Brand } from '../modules/catalog/entities/brand.entity'; import { Category } from '../modules/catalog/entities/category.entity'; +import { ProductAttributeValue } from '../modules/catalog/entities/product-attribute-value.entity'; +import { ProductMeta } from '../modules/catalog/entities/product-meta.entity'; import { Product } from '../modules/catalog/entities/product.entity'; +import { ProductReview } from '../modules/catalog/entities/product-review.entity'; +import { MediaAsset } from '../modules/media/entities/media-asset.entity'; +import { LoyaltyProfile } from '../modules/users/entities/loyalty-profile.entity'; import { User } from '../modules/users/entities/user.entity'; +import { UserLevelHistory } from '../modules/users/entities/user-level-history.entity'; +import { WalletTransaction } from '../modules/users/entities/wallet-transaction.entity'; +import { Wallet } from '../modules/users/entities/wallet.entity'; export const buildTypeOrmOptions = ( configService: ConfigService, -): TypeOrmModuleOptions => ({ - type: 'postgres', - url: configService.get('database.url'), - entities: [User, Product, Category], - autoLoadEntities: false, - synchronize: true, -}); +): TypeOrmModuleOptions => { + const sslEnabled = configService.get('database.ssl', false); + + return { + type: 'postgres', + url: configService.get('database.url'), + ssl: sslEnabled ? { rejectUnauthorized: false } : false, + extra: sslEnabled ? { ssl: { rejectUnauthorized: false } } : {}, + entities: [ + User, + Wallet, + WalletTransaction, + LoyaltyProfile, + UserLevelHistory, + AuthOtp, + UserSession, + Product, + Category, + Brand, + ProductReview, + ProductMeta, + AttributeDefinition, + ProductAttributeValue, + MediaAsset, + ], + autoLoadEntities: false, + synchronize: true, + }; +}; export const typeOrmConfigFactory: TypeOrmModuleAsyncOptions = { imports: [ConfigModule], diff --git a/src/main.ts b/src/main.ts index 7e6aef71..24950ba5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory, Reflector } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; @@ -20,6 +21,15 @@ async function bootstrap() { ); app.useGlobalInterceptors(new ResponseInterceptor(reflector)); + const swaggerConfig = new DocumentBuilder() + .setTitle('ParsShop API') + .setDescription('Phase 1 API documentation for ParsShop') + .setVersion('1.0.0') + .addBearerAuth() + .build(); + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('docs', app, swaggerDocument); + await app.listen(process.env.PORT ?? 3000); } diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 7c77eac7..e1f49128 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -7,18 +7,22 @@ import { UseGuards, } from '@nestjs/common'; import { Request } from 'express'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Permissions } from '../../common/decorators/permissions.decorator'; import { Roles } from '../../common/decorators/roles.decorator'; import { PermissionsGuard } from '../../common/guards/permissions.guard'; import { RolesGuard } from '../../common/guards/roles.guard'; import { UserRole } from '../users/enums/user-role.enum'; import { AuthService } from './auth.service'; +import { LoginPasswordDto } from './dto/login-password.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { RegisterPasswordDto } from './dto/register-password.dto'; import { RequestOtpDto } from './dto/request-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtPayload } from './interfaces/jwt-payload.interface'; +@ApiTags('Auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @@ -28,6 +32,16 @@ export class AuthController { return this.authService.requestOtp(dto.phone, dto.fullName); } + @Post('register/password') + registerWithPassword(@Body() dto: RegisterPasswordDto) { + return this.authService.registerWithPassword(dto); + } + + @Post('login/password') + loginWithPassword(@Body() dto: LoginPasswordDto) { + return this.authService.loginWithPassword(dto); + } + @Post('otp/verify') verifyOtp(@Body() dto: VerifyOtpDto) { return this.authService.verifyOtp(dto.phone, dto.otp); @@ -39,12 +53,14 @@ export class AuthController { } @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @Post('logout') logout(@Req() request: Request & { user: JwtPayload }) { return this.authService.logout(request.user.sub); } @UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) + @ApiBearerAuth() @Roles(UserRole.ADMIN) @Permissions('users.manage') @Get('me/admin-check') diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index a1c40379..a4a2d12a 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -2,8 +2,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { AuthOtp } from './entities/auth-otp.entity'; +import { UserSession } from './entities/user-session.entity'; +import { SmsService } from './sms.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { UsersModule } from '../users/users.module'; import { RolesGuard } from '../../common/guards/roles.guard'; @@ -14,6 +18,7 @@ import { PermissionsGuard } from '../../common/guards/permissions.guard'; UsersModule, PassportModule, ConfigModule, + TypeOrmModule.forFeature([AuthOtp, UserSession]), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -23,7 +28,7 @@ import { PermissionsGuard } from '../../common/guards/permissions.guard'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, RolesGuard, PermissionsGuard], + providers: [AuthService, SmsService, JwtStrategy, RolesGuard, PermissionsGuard], exports: [AuthService], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 6b593751..a6c0f3a3 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -4,13 +4,21 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { StringValue } from 'ms'; +import { IsNull, Repository } from 'typeorm'; +import { AuthOtp } from './entities/auth-otp.entity'; +import { UserSession } from './entities/user-session.entity'; import { User } from '../users/entities/user.entity'; +import { UserLevel } from '../users/enums/user-level.enum'; import { UserRole } from '../users/enums/user-role.enum'; import { UsersService } from '../users/users.service'; +import { LoginPasswordDto } from './dto/login-password.dto'; +import { RegisterPasswordDto } from './dto/register-password.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; +import { SmsService } from './sms.service'; @Injectable() export class AuthService { @@ -18,43 +26,109 @@ export class AuthService { private readonly usersService: UsersService, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly smsService: SmsService, + @InjectRepository(AuthOtp) + private readonly authOtpsRepository: Repository, + @InjectRepository(UserSession) + private readonly userSessionsRepository: Repository, ) {} async requestOtp(phone: string, fullName?: string) { const user = await this.usersService.findOrCreateByPhone(phone, fullName); const otpCode = this.generateOtp(); const ttlSeconds = this.configService.get('otp.ttlSeconds', 120); - - user.otpCode = otpCode; - user.otpExpiresAt = new Date(Date.now() + ttlSeconds * 1000); - await this.usersService.save(user); + const otp = this.authOtpsRepository.create({ + phone: user.phone, + codeHash: await bcrypt.hash(otpCode, 10), + purpose: 'login', + expiresAt: new Date(Date.now() + ttlSeconds * 1000), + attemptCount: 0, + }); + await this.authOtpsRepository.save(otp); + const smsSent = await this.smsService.sendOtp(phone, otpCode); return { message: 'OTP generated successfully', expiresInSeconds: ttlSeconds, phone, - otpPreview: otpCode, + smsSent, + otpPreview: + this.configService.get('app.nodeEnv') === 'development' + ? otpCode + : undefined, }; } + async registerWithPassword(dto: RegisterPasswordDto) { + const existingPhone = await this.usersService.findByPhone(dto.phone); + if (existingPhone) { + throw new BadRequestException('Phone already exists'); + } + + const existingUsername = await this.usersService.findByUsername(dto.username); + if (existingUsername) { + throw new BadRequestException('Username already exists'); + } + + const savedUser = await this.usersService.create({ + phone: dto.phone, + username: dto.username, + fullName: dto.fullName ?? dto.username, + passwordHash: await bcrypt.hash(dto.password, 10), + isVerified: true, + role: UserRole.USER, + }); + const tokens = await this.issueTokens(savedUser); + await this.storeRefreshToken(savedUser, tokens.refreshToken); + + return tokens; + } + + async loginWithPassword(dto: LoginPasswordDto) { + const user = await this.usersService.findByUsername(dto.username); + if (!user?.passwordHash) { + throw new UnauthorizedException('Invalid username or password'); + } + + const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash); + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid username or password'); + } + + const tokens = await this.issueTokens(user); + await this.storeRefreshToken(user, tokens.refreshToken); + + return tokens; + } + async verifyOtp(phone: string, otp: string) { const user = await this.usersService.findByPhone(phone); + const otpRecord = await this.authOtpsRepository.findOne({ + where: { phone, purpose: 'login', usedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); - if (!user || !user.otpCode || !user.otpExpiresAt) { + if (!user || !otpRecord) { throw new UnauthorizedException('OTP not requested'); } - if (user.otpExpiresAt.getTime() < Date.now()) { + if (otpRecord.expiresAt.getTime() < Date.now()) { throw new UnauthorizedException('OTP expired'); } - if (user.otpCode !== otp) { + const isOtpValid = await bcrypt.compare(otp, otpRecord.codeHash); + if (!isOtpValid) { + otpRecord.attemptCount += 1; + await this.authOtpsRepository.save(otpRecord); throw new BadRequestException('Invalid OTP'); } user.isVerified = true; - user.otpCode = null; - user.otpExpiresAt = null; + otpRecord.usedAt = new Date(); + await Promise.all([ + this.usersService.save(user), + this.authOtpsRepository.save(otpRecord), + ]); const tokens = await this.issueTokens(user); await this.storeRefreshToken(user, tokens.refreshToken); @@ -72,17 +146,28 @@ export class AuthService { } const user = await this.usersService.findByPhone(payload.phone); - - if (!user?.refreshTokenHash) { + if (!user) { throw new UnauthorizedException('Refresh token not found'); } - const isValid = await bcrypt.compare(refreshToken, user.refreshTokenHash); + const sessions = await this.userSessionsRepository.find({ + where: { + user: { id: user.id }, + revokedAt: IsNull(), + }, + relations: { user: true }, + order: { createdAt: 'DESC' }, + }); - if (!isValid) { + const validSession = await this.findMatchingSession(sessions, refreshToken); + + if (!validSession || validSession.expiresAt.getTime() < Date.now()) { throw new UnauthorizedException('Invalid refresh token'); } + validSession.revokedAt = new Date(); + await this.userSessionsRepository.save(validSession); + const tokens = await this.issueTokens(user); await this.storeRefreshToken(user, tokens.refreshToken); @@ -91,18 +176,24 @@ export class AuthService { async logout(userId: string) { const user = await this.findUserById(userId); - user.refreshTokenHash = null; - await this.usersService.save(user); + await this.userSessionsRepository + .createQueryBuilder() + .update(UserSession) + .set({ revokedAt: new Date() }) + .where('userId = :userId', { userId: user.id }) + .andWhere('revoked_at IS NULL') + .execute(); return { message: 'Logged out successfully' }; } private async issueTokens(user: User) { + const currentLevel = user.loyaltyProfile?.currentLevel ?? UserLevel.BRONZE; const accessPayload: JwtPayload = { sub: user.id, phone: user.phone, role: user.role, - level: user.level, + level: currentLevel, permissions: this.resolvePermissions(user), type: 'access', }; @@ -131,14 +222,19 @@ export class AuthService { phone: user.phone, fullName: user.fullName, role: user.role, - level: user.level, + level: currentLevel, }, }; } private async storeRefreshToken(user: User, refreshToken: string) { - user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); - await this.usersService.save(user); + const refreshTtl = this.configService.getOrThrow('jwt.refreshTtl'); + const session = this.userSessionsRepository.create({ + user, + refreshTokenHash: await bcrypt.hash(refreshToken, 10), + expiresAt: new Date(Date.now() + this.parseDurationToMs(refreshTtl)), + }); + await this.userSessionsRepository.save(session); } private generateOtp() { @@ -147,7 +243,13 @@ export class AuthService { private resolvePermissions(user: User) { if (user.role === UserRole.ADMIN) { - return ['products.manage', 'categories.manage', 'users.manage']; + return [ + 'products.manage', + 'categories.manage', + 'brands.manage', + 'users.manage', + 'media.manage', + ]; } if (user.role === UserRole.AGENT) { @@ -166,4 +268,37 @@ export class AuthService { return user; } + + private async findMatchingSession( + sessions: UserSession[], + refreshToken: string, + ): Promise { + for (const session of sessions) { + const isValid = await bcrypt.compare(refreshToken, session.refreshTokenHash); + if (isValid) { + return session; + } + } + + return null; + } + + private parseDurationToMs(value: StringValue) { + const match = /^(\d+)(ms|s|m|h|d)$/i.exec(value); + if (!match) { + throw new BadRequestException(`Unsupported duration format: ${value}`); + } + + const amount = Number(match[1]); + const unit = match[2].toLowerCase(); + const unitMap: Record = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + }; + + return amount * unitMap[unit]; + } } diff --git a/src/modules/auth/dto/login-password.dto.ts b/src/modules/auth/dto/login-password.dto.ts new file mode 100644 index 00000000..b64205b9 --- /dev/null +++ b/src/modules/auth/dto/login-password.dto.ts @@ -0,0 +1,13 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class LoginPasswordDto { + @IsString() + @MinLength(3) + @MaxLength(50) + username: string; + + @IsString() + @MinLength(6) + @MaxLength(100) + password: string; +} diff --git a/src/modules/auth/dto/register-password.dto.ts b/src/modules/auth/dto/register-password.dto.ts new file mode 100644 index 00000000..2475ed45 --- /dev/null +++ b/src/modules/auth/dto/register-password.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator'; + +export class RegisterPasswordDto { + @Matches(/^\+?[1-9]\d{7,14}$/) + phone: string; + + @IsString() + @MinLength(3) + @MaxLength(50) + username: string; + + @IsString() + @MinLength(6) + @MaxLength(100) + password: string; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(150) + fullName?: string; +} diff --git a/src/modules/auth/entities/auth-otp.entity.ts b/src/modules/auth/entities/auth-otp.entity.ts new file mode 100644 index 00000000..4685390a --- /dev/null +++ b/src/modules/auth/entities/auth-otp.entity.ts @@ -0,0 +1,39 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'auth_otps' }) +export class AuthOtp { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ length: 20 }) + phone: string; + + @Column({ name: 'code_hash', length: 255 }) + codeHash: string; + + @Column({ length: 30, default: 'login' }) + purpose: string; + + @Column({ name: 'expires_at', type: 'timestamp with time zone' }) + expiresAt: Date; + + @Column({ name: 'used_at', type: 'timestamp with time zone', nullable: true }) + usedAt?: Date | null; + + @Column({ name: 'attempt_count', type: 'int', default: 0 }) + attemptCount: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/auth/entities/user-session.entity.ts b/src/modules/auth/entities/user-session.entity.ts new file mode 100644 index 00000000..e0a36228 --- /dev/null +++ b/src/modules/auth/entities/user-session.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity({ name: 'user_sessions' }) +export class UserSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @ManyToOne(() => User, (user) => user.sessions, { onDelete: 'CASCADE' }) + user: User; + + @Column({ name: 'refresh_token_hash', length: 255 }) + refreshTokenHash: string; + + @Column({ name: 'expires_at', type: 'timestamp with time zone' }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamp with time zone', nullable: true }) + revokedAt?: Date | null; + + @Column({ name: 'device_info', type: 'varchar', length: 255, nullable: true }) + deviceInfo?: string | null; + + @Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true }) + ipAddress?: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/auth/sms.service.ts b/src/modules/auth/sms.service.ts new file mode 100644 index 00000000..483550b4 --- /dev/null +++ b/src/modules/auth/sms.service.ts @@ -0,0 +1,50 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createClientAsync } from 'soap'; + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor(private readonly configService: ConfigService) {} + + async sendOtp(mobile: string, otpCode: string): Promise { + const wsdlUrl = this.configService.get('sms.wsdlUrl'); + const username = this.configService.get('sms.username'); + const password = this.configService.get('sms.password'); + const fromNumber = this.configService.get('sms.fromNumber'); + + if (!wsdlUrl || !username || !password || !fromNumber) { + this.logger.warn('SMS provider config is incomplete, OTP SMS skipped.'); + return false; + } + + const content = `آکادمی زبان آلمانی انجل\nکد تایید شما: ${otpCode}\nلغو11`; + + try { + const client = await createClientAsync(wsdlUrl); + const [result] = await client.SendSMSAsync( + fromNumber, + [mobile], + content, + '0', + username, + password, + ); + + if (Array.isArray(result) && result[0] > 0) { + return true; + } + + if (typeof result === 'number' && result > 0) { + return true; + } + + this.logger.warn(`SMS provider returned unsuccessful response: ${JSON.stringify(result)}`); + return false; + } catch (error) { + this.logger.error('SOAP SMS send failed', error instanceof Error ? error.stack : undefined); + throw new InternalServerErrorException('OTP SMS sending failed'); + } + } +} diff --git a/src/modules/catalog/admin-products.controller.ts b/src/modules/catalog/admin-products.controller.ts new file mode 100644 index 00000000..24d4aedc --- /dev/null +++ b/src/modules/catalog/admin-products.controller.ts @@ -0,0 +1,136 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Permissions } from '../../common/decorators/permissions.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CheckProductSlugDto } from './dto/check-product-slug.dto'; +import { CreateProductDto } from './dto/create-product.dto'; +import { FilterProductReviewsDto } from './dto/filter-product-reviews.dto'; +import { FilterProductsDto } from './dto/filter-products.dto'; +import { ModerateProductReviewDto } from './dto/moderate-product-review.dto'; +import { UpdateProductDto } from './dto/update-product.dto'; +import { ProductsService } from './products.service'; + +@ApiTags('Admin Products') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) +@Roles(UserRole.ADMIN) +@Permissions('products.manage') +@Controller('admin/products') +export class AdminProductsController { + constructor(private readonly productsService: ProductsService) {} + + @Post() + @ApiOperation({ summary: 'Create a product for admin panel' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: CreateProductDto }) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'mainImage', maxCount: 1 }, + { name: 'images', maxCount: 10 }, + { name: 'model3d', maxCount: 1 }, + ]), + ) + create( + @Body() dto: CreateProductDto, + @UploadedFiles() + files: { + mainImage?: Express.Multer.File[]; + images?: Express.Multer.File[]; + model3d?: Express.Multer.File[]; + }, + ) { + return this.productsService.create(dto, files); + } + + @Get() + @ApiOperation({ summary: 'List all products for admin panel, including drafts' }) + findAll(@Query() filters: FilterProductsDto) { + return this.productsService.findAdmin(filters); + } + + @Get('check-slug') + @ApiOperation({ summary: 'Check whether a product slug is available for admin create/edit' }) + checkSlug(@Query() query: CheckProductSlugDto) { + return this.productsService.checkSlugAvailability(query.slug, query.excludeId); + } + + @Get('reviews/list') + @ApiOperation({ summary: 'List product reviews for moderation' }) + findReviews(@Query() filters: FilterProductReviewsDto) { + return this.productsService.findAdminReviews(filters); + } + + @Patch('reviews/:reviewId') + @ApiOperation({ summary: 'Approve or pin a product review' }) + updateReview( + @Param('reviewId') reviewId: string, + @Body() dto: ModerateProductReviewDto, + ) { + return this.productsService.updateReview(reviewId, dto); + } + + @Delete('reviews/:reviewId') + @ApiOperation({ summary: 'Delete a product review' }) + removeReview(@Param('reviewId') reviewId: string) { + return this.productsService.removeReview(reviewId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one product for admin panel' }) + findOne(@Param('id') id: string) { + return this.productsService.findAdminOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a product for admin panel' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: UpdateProductDto }) + @UseInterceptors( + FileFieldsInterceptor([ + { name: 'mainImage', maxCount: 1 }, + { name: 'images', maxCount: 10 }, + { name: 'model3d', maxCount: 1 }, + ]), + ) + update( + @Param('id') id: string, + @Body() dto: UpdateProductDto, + @UploadedFiles() + files: { + mainImage?: Express.Multer.File[]; + images?: Express.Multer.File[]; + model3d?: Express.Multer.File[]; + }, + ) { + return this.productsService.update(id, dto, files); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a product and its assets' }) + remove(@Param('id') id: string) { + return this.productsService.remove(id); + } +} diff --git a/src/modules/catalog/attribute-definitions.controller.ts b/src/modules/catalog/attribute-definitions.controller.ts new file mode 100644 index 00000000..6656e8ac --- /dev/null +++ b/src/modules/catalog/attribute-definitions.controller.ts @@ -0,0 +1,54 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Permissions } from '../../common/decorators/permissions.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto'; +import { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto'; +import { ProductsService } from './products.service'; + +@ApiTags('Admin Product Attributes') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) +@Roles(UserRole.ADMIN) +@Permissions('products.manage') +@Controller('admin/product-attributes') +export class AttributeDefinitionsController { + constructor(private readonly productsService: ProductsService) {} + + @Get() + @ApiOperation({ summary: 'List reusable product attribute definitions' }) + findAll() { + return this.productsService.listAttributeDefinitions(); + } + + @Post() + @ApiOperation({ summary: 'Create reusable product attribute definition' }) + create(@Body() dto: CreateAttributeDefinitionDto) { + return this.productsService.createAttributeDefinition(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update reusable product attribute definition' }) + update(@Param('id') id: string, @Body() dto: UpdateAttributeDefinitionDto) { + return this.productsService.updateAttributeDefinition(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete reusable product attribute definition' }) + remove(@Param('id') id: string) { + return this.productsService.removeAttributeDefinition(id); + } +} diff --git a/src/modules/catalog/brand.controller.ts b/src/modules/catalog/brand.controller.ts new file mode 100644 index 00000000..d17db88b --- /dev/null +++ b/src/modules/catalog/brand.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Permissions } from '../../common/decorators/permissions.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { BrandService } from './brand.service'; +import { CreateBrandDto } from './dto/create-brand.dto'; +import { UpdateBrandDto } from './dto/update-brand.dto'; + +@ApiTags('Brands') +@Controller('brands') +export class BrandController { + constructor(private readonly brandService: BrandService) {} + + @UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) + @ApiBearerAuth() + @Roles(UserRole.ADMIN) + @Permissions('brands.manage') + @Post() + @ApiOperation({ summary: 'Create brand with optional uploaded or existing image' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: CreateBrandDto }) + @UseInterceptors(FileFieldsInterceptor([{ name: 'image', maxCount: 1 }])) + create( + @Body() dto: CreateBrandDto, + @UploadedFiles() files: { image?: Express.Multer.File[] }, + ) { + return this.brandService.create(dto, files); + } + + @Get() + findAll() { + return this.brandService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.brandService.findOne(id); + } + + @UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) + @ApiBearerAuth() + @Roles(UserRole.ADMIN) + @Permissions('brands.manage') + @Patch(':id') + @ApiOperation({ summary: 'Update brand and brand image' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: UpdateBrandDto }) + @UseInterceptors(FileFieldsInterceptor([{ name: 'image', maxCount: 1 }])) + update( + @Param('id') id: string, + @Body() dto: UpdateBrandDto, + @UploadedFiles() files: { image?: Express.Multer.File[] }, + ) { + return this.brandService.update(id, dto, files); + } + + @UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) + @ApiBearerAuth() + @Roles(UserRole.ADMIN) + @Permissions('brands.manage') + @Delete(':id') + remove(@Param('id') id: string) { + return this.brandService.remove(id); + } +} diff --git a/src/modules/catalog/brand.service.ts b/src/modules/catalog/brand.service.ts new file mode 100644 index 00000000..a8643b5c --- /dev/null +++ b/src/modules/catalog/brand.service.ts @@ -0,0 +1,86 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { StorageService } from '../storage/storage.service'; +import { CreateBrandDto } from './dto/create-brand.dto'; +import { UpdateBrandDto } from './dto/update-brand.dto'; +import { Brand } from './entities/brand.entity'; + +@Injectable() +export class BrandService { + constructor( + @InjectRepository(Brand) + private readonly brandsRepository: Repository, + private readonly storageService: StorageService, + ) {} + + async create(dto: CreateBrandDto, files?: { image?: Express.Multer.File[] }) { + const imageUpload = files?.image?.[0] + ? await this.storageService.uploadPublicFile(files.image[0], 'brands') + : null; + + const brand = this.brandsRepository.create({ + name: dto.name, + slug: dto.slug, + imageUrl: imageUpload?.url ?? dto.existingImageUrl ?? null, + type: dto.type, + }); + + return this.brandsRepository.save(brand); + } + + findAll() { + return this.brandsRepository.find({ + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: string) { + const brand = await this.brandsRepository.findOne({ where: { id } }); + if (!brand) { + throw new NotFoundException('Brand not found'); + } + + return brand; + } + + async update(id: string, dto: UpdateBrandDto, files?: { image?: Express.Multer.File[] }) { + const brand = await this.findOne(id); + + if (files?.image?.[0]) { + const imageUpload = await this.storageService.uploadPublicFile( + files.image[0], + 'brands', + ); + await this.replaceImage(brand.imageUrl, imageUpload.url); + brand.imageUrl = imageUpload.url; + } else if (dto.existingImageUrl !== undefined) { + await this.replaceImage(brand.imageUrl, dto.existingImageUrl || null); + brand.imageUrl = dto.existingImageUrl || null; + } + + Object.assign(brand, { + name: dto.name ?? brand.name, + slug: dto.slug ?? brand.slug, + type: dto.type ?? brand.type, + }); + + return this.brandsRepository.save(brand); + } + + async remove(id: string) { + const brand = await this.findOne(id); + await this.storageService.deletePublicFileByUrl(brand.imageUrl); + await this.brandsRepository.remove(brand); + return { message: 'Brand deleted successfully' }; + } + + private async replaceImage(currentUrl?: string | null, nextUrl?: string | null) { + if (currentUrl && currentUrl !== nextUrl) { + await this.storageService.deletePublicFileByUrl(currentUrl); + } + } +} diff --git a/src/modules/catalog/catalog.module.ts b/src/modules/catalog/catalog.module.ts index 114c057f..dba4cfb7 100644 --- a/src/modules/catalog/catalog.module.ts +++ b/src/modules/catalog/catalog.module.ts @@ -1,10 +1,43 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageModule } from '../storage/storage.module'; +import { AdminProductsController } from './admin-products.controller'; +import { AttributeDefinitionsController } from './attribute-definitions.controller'; +import { BrandController } from './brand.controller'; +import { BrandService } from './brand.service'; +import { CategoryController } from './category.controller'; +import { CategoryService } from './category.service'; +import { AttributeDefinition } from './entities/attribute-definition.entity'; +import { Brand } from './entities/brand.entity'; import { Category } from './entities/category.entity'; +import { ProductAttributeValue } from './entities/product-attribute-value.entity'; +import { ProductMeta } from './entities/product-meta.entity'; import { Product } from './entities/product.entity'; +import { ProductReview } from './entities/product-review.entity'; +import { ProductsController } from './products.controller'; +import { ProductsService } from './products.service'; @Module({ - imports: [TypeOrmModule.forFeature([Category, Product])], - exports: [TypeOrmModule], + imports: [ + TypeOrmModule.forFeature([ + Category, + Brand, + Product, + ProductReview, + ProductMeta, + AttributeDefinition, + ProductAttributeValue, + ]), + StorageModule, + ], + controllers: [ + CategoryController, + BrandController, + ProductsController, + AdminProductsController, + AttributeDefinitionsController, + ], + providers: [CategoryService, BrandService, ProductsService], + exports: [TypeOrmModule, CategoryService, BrandService, ProductsService], }) export class CatalogModule {} diff --git a/src/modules/catalog/category.controller.ts b/src/modules/catalog/category.controller.ts new file mode 100644 index 00000000..e0fa3bb4 --- /dev/null +++ b/src/modules/catalog/category.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Permissions } from '../../common/decorators/permissions.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CategoryService } from './category.service'; +import { CreateCategoryDto } from './dto/create-category.dto'; +import { UpdateCategoryDto } from './dto/update-category.dto'; + +@ApiTags('Categories') +@Controller('categories') +export class CategoryController { + constructor(private readonly categoryService: CategoryService) {} + + @UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) + @ApiBearerAuth() + @Roles(UserRole.ADMIN) + @Permissions('categories.manage') + @Post() + @ApiOperation({ summary: 'Create category with optional uploaded or existing image' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: CreateCategoryDto }) + @UseInterceptors(FileFieldsInterceptor([{ name: 'image', maxCount: 1 }])) + create( + @Body() dto: CreateCategoryDto, + @UploadedFiles() files: { image?: Express.Multer.File[] }, + ) { + return this.categoryService.create(dto, files); + } + + @Get() + findAll() { + return this.categoryService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.categoryService.findOne(id); + } + + @UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) + @ApiBearerAuth() + @Roles(UserRole.ADMIN) + @Permissions('categories.manage') + @Patch(':id') + @ApiOperation({ summary: 'Update category and category image' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: UpdateCategoryDto }) + @UseInterceptors(FileFieldsInterceptor([{ name: 'image', maxCount: 1 }])) + update( + @Param('id') id: string, + @Body() dto: UpdateCategoryDto, + @UploadedFiles() files: { image?: Express.Multer.File[] }, + ) { + return this.categoryService.update(id, dto, files); + } + + @UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) + @ApiBearerAuth() + @Roles(UserRole.ADMIN) + @Permissions('categories.manage') + @Delete(':id') + remove(@Param('id') id: string) { + return this.categoryService.remove(id); + } +} diff --git a/src/modules/catalog/category.service.ts b/src/modules/catalog/category.service.ts new file mode 100644 index 00000000..678717a1 --- /dev/null +++ b/src/modules/catalog/category.service.ts @@ -0,0 +1,126 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { StorageService } from '../storage/storage.service'; +import { CreateCategoryDto } from './dto/create-category.dto'; +import { UpdateCategoryDto } from './dto/update-category.dto'; +import { Category } from './entities/category.entity'; + +@Injectable() +export class CategoryService { + constructor( + @InjectRepository(Category) + private readonly categoriesRepository: Repository, + private readonly storageService: StorageService, + ) {} + + async create(dto: CreateCategoryDto, files?: { image?: Express.Multer.File[] }) { + const parent = dto.parentId + ? await this.categoriesRepository.findOne({ where: { id: dto.parentId } }) + : null; + + if (dto.parentId && !parent) { + throw new NotFoundException('Parent category not found'); + } + + if (parent && parent.type !== dto.type) { + throw new BadRequestException('Child category type must match parent category type'); + } + + const imageUpload = files?.image?.[0] + ? await this.storageService.uploadPublicFile(files.image[0], 'categories') + : null; + + const category = this.categoriesRepository.create({ + name: dto.name, + slug: dto.slug, + imageUrl: imageUpload?.url ?? dto.existingImageUrl ?? null, + type: dto.type, + parent, + }); + + return this.categoriesRepository.save(category); + } + + findAll() { + return this.categoriesRepository.find({ + relations: { parent: true, children: true }, + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: string) { + const category = await this.categoriesRepository.findOne({ + where: { id }, + relations: { parent: true, children: true }, + }); + + if (!category) { + throw new NotFoundException('Category not found'); + } + + return category; + } + + async update( + id: string, + dto: UpdateCategoryDto, + files?: { image?: Express.Multer.File[] }, + ) { + const category = await this.findOne(id); + + let parent = category.parent ?? null; + if (dto.parentId !== undefined) { + parent = dto.parentId + ? await this.categoriesRepository.findOne({ where: { id: dto.parentId } }) + : null; + + if (dto.parentId && !parent) { + throw new NotFoundException('Parent category not found'); + } + } + + const nextType = dto.type ?? category.type; + if (parent && parent.type !== nextType) { + throw new BadRequestException('Child category type must match parent category type'); + } + + if (files?.image?.[0]) { + const imageUpload = await this.storageService.uploadPublicFile( + files.image[0], + 'categories', + ); + await this.replaceImage(category.imageUrl, imageUpload.url); + category.imageUrl = imageUpload.url; + } else if (dto.existingImageUrl !== undefined) { + await this.replaceImage(category.imageUrl, dto.existingImageUrl || null); + category.imageUrl = dto.existingImageUrl || null; + } + + Object.assign(category, { + name: dto.name ?? category.name, + slug: dto.slug ?? category.slug, + type: nextType, + parent, + }); + + return this.categoriesRepository.save(category); + } + + async remove(id: string) { + const category = await this.findOne(id); + await this.storageService.deletePublicFileByUrl(category.imageUrl); + await this.categoriesRepository.remove(category); + return { message: 'Category deleted successfully' }; + } + + private async replaceImage(currentUrl?: string | null, nextUrl?: string | null) { + if (currentUrl && currentUrl !== nextUrl) { + await this.storageService.deletePublicFileByUrl(currentUrl); + } + } +} diff --git a/src/modules/catalog/dto/check-product-slug.dto.ts b/src/modules/catalog/dto/check-product-slug.dto.ts new file mode 100644 index 00000000..7a973e72 --- /dev/null +++ b/src/modules/catalog/dto/check-product-slug.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +export class CheckProductSlugDto { + @ApiProperty({ example: 'skf-6006-2rs' }) + @IsString() + @MaxLength(180) + slug: string; + + @ApiPropertyOptional({ example: '8dd5d8c7-8a74-4925-894f-6f4a95a83184' }) + @IsOptional() + @IsUUID() + excludeId?: string; +} diff --git a/src/modules/catalog/dto/create-attribute-definition.dto.ts b/src/modules/catalog/dto/create-attribute-definition.dto.ts new file mode 100644 index 00000000..f1a5605e --- /dev/null +++ b/src/modules/catalog/dto/create-attribute-definition.dto.ts @@ -0,0 +1,57 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { parseJsonValue } from '../../../common/utils/json-transform.util'; +import { AttributeDataType } from '../enums/attribute-data-type.enum'; + +export class CreateAttributeDefinitionDto { + @ApiProperty({ example: 'Weight' }) + @IsString() + @MaxLength(120) + name: string; + + @ApiProperty({ example: 'weight' }) + @IsString() + @MaxLength(140) + slug: string; + + @ApiProperty({ enum: AttributeDataType }) + @IsEnum(AttributeDataType) + dataType: AttributeDataType; + + @ApiPropertyOptional({ example: 'kg' }) + @IsOptional() + @IsString() + @MaxLength(50) + unit?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @Transform(parseJsonValue) + @IsArray() + @IsString({ each: true }) + options?: string[]; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isFilterable?: boolean; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isVisible?: boolean; +} diff --git a/src/modules/catalog/dto/create-brand.dto.ts b/src/modules/catalog/dto/create-brand.dto.ts new file mode 100644 index 00000000..ee7c873a --- /dev/null +++ b/src/modules/catalog/dto/create-brand.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; +import { ProductType } from '../enums/product-type.enum'; + +export class CreateBrandDto { + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(150) + name: string; + + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(180) + slug: string; + + @ApiPropertyOptional({ example: 'https://cdn.example.com/media/image/root/brand.jpg' }) + @IsOptional() + @IsString() + @MaxLength(500) + existingImageUrl?: string; + + @ApiProperty({ enum: ProductType }) + @IsEnum(ProductType) + type: ProductType; +} diff --git a/src/modules/catalog/dto/create-category.dto.ts b/src/modules/catalog/dto/create-category.dto.ts new file mode 100644 index 00000000..16db3e9d --- /dev/null +++ b/src/modules/catalog/dto/create-category.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, MaxLength, MinLength, IsUUID } from 'class-validator'; +import { ProductType } from '../enums/product-type.enum'; + +export class CreateCategoryDto { + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(150) + name: string; + + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(180) + slug: string; + + @ApiPropertyOptional({ example: 'https://cdn.example.com/media/image/root/category.jpg' }) + @IsOptional() + @IsString() + @MaxLength(500) + existingImageUrl?: string; + + @ApiProperty({ enum: ProductType }) + @IsEnum(ProductType) + type: ProductType; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + parentId?: string; +} diff --git a/src/modules/catalog/dto/create-product-review.dto.ts b/src/modules/catalog/dto/create-product-review.dto.ts new file mode 100644 index 00000000..774aefdb --- /dev/null +++ b/src/modules/catalog/dto/create-product-review.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + Max, +} from 'class-validator'; + +export class CreateProductReviewDto { + @ApiProperty({ example: 'Ali Rezaei' }) + @IsString() + @MaxLength(120) + name: string; + + @ApiPropertyOptional({ example: 'ali@example.com' }) + @IsOptional() + @IsEmail() + @MaxLength(160) + email?: string; + + @ApiProperty({ example: 5, minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + rating: number; + + @ApiPropertyOptional({ example: 'Excellent quality' }) + @IsOptional() + @IsString() + @MaxLength(160) + title?: string; + + @ApiProperty({ example: 'Good quality product with solid packaging.' }) + @IsString() + comment: string; +} diff --git a/src/modules/catalog/dto/create-product.dto.ts b/src/modules/catalog/dto/create-product.dto.ts new file mode 100644 index 00000000..48428e85 --- /dev/null +++ b/src/modules/catalog/dto/create-product.dto.ts @@ -0,0 +1,161 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; +import { parseJsonValue } from '../../../common/utils/json-transform.util'; +import { ProductStatus } from '../enums/product-status.enum'; +import { ProductType } from '../enums/product-type.enum'; +import { ProductAttributeInputDto } from './product-attribute-input.dto'; +import { ProductMetaDto } from './product-meta.dto'; + +export class CreateProductDto { + @ApiProperty({ example: 'BRG-6006-2RS' }) + @IsString() + @MaxLength(80) + sku: string; + + @ApiProperty({ example: 'SKF 6006-2RS Deep Groove Bearing' }) + @IsString() + @MaxLength(160) + title: string; + + @ApiProperty({ example: 'skf-6006-2rs' }) + @IsString() + @MaxLength(180) + slug: string; + + @ApiProperty({ example: 'SKF-6006' }) + @IsString() + @MaxLength(120) + technicalCode: string; + + @ApiProperty({ example: 'SKF' }) + @IsOptional() + @IsString() + @MaxLength(120) + brand?: string; + + @ApiPropertyOptional({ example: '1cc8af97-a766-49af-80f4-912710ef9b2f' }) + @IsOptional() + @IsUUID() + brandId?: string; + + @ApiProperty({ example: 42.5 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + basePriceUSD: number; + + @ApiPropertyOptional({ example: 39.99 }) + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + salePriceUSD?: number; + + @ApiProperty({ example: 28 }) + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(0) + stock: number; + + @ApiProperty({ enum: ProductType }) + @IsEnum(ProductType) + type: ProductType; + + @ApiPropertyOptional({ enum: ProductStatus, example: ProductStatus.DRAFT }) + @IsOptional() + @IsEnum(ProductStatus) + status?: ProductStatus; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + featured?: boolean; + + @ApiPropertyOptional({ example: '1cc8af97-a766-49af-80f4-912710ef9b2f' }) + @IsOptional() + @IsUUID() + primaryCategoryId?: string; + + @ApiPropertyOptional({ + example: [ + '1cc8af97-a766-49af-80f4-912710ef9b2f', + '2cc8af97-a766-49af-80f4-912710ef9b2f', + ], + type: [String], + }) + @IsOptional() + @Transform(parseJsonValue) + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + categoryIds?: string[]; + + @ApiPropertyOptional({ + example: ['bearing', 'skf', 'industrial'], + type: [String], + }) + @IsOptional() + @Transform(parseJsonValue) + @IsArray() + @ArrayMaxSize(30) + @ArrayUnique() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ type: ProductMetaDto }) + @IsOptional() + @Transform(parseJsonValue) + @ValidateNested() + @Type(() => ProductMetaDto) + meta?: ProductMetaDto; + + @ApiPropertyOptional({ type: [ProductAttributeInputDto] }) + @IsOptional() + @Transform(parseJsonValue) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductAttributeInputDto) + attributes?: ProductAttributeInputDto[]; + + @ApiPropertyOptional({ + example: ['https://cdn.example.com/products/gallery/1.jpg'], + type: [String], + }) + @IsOptional() + @Transform(parseJsonValue) + @IsArray() + @ArrayMaxSize(20) + @IsString({ each: true }) + existingGalleryUrls?: string[]; + + @ApiPropertyOptional({ example: 'https://cdn.example.com/products/main.jpg' }) + @IsOptional() + @IsString() + @IsNotEmpty() + existingMainImageUrl?: string; + + @ApiPropertyOptional({ example: 'https://cdn.example.com/products/model.glb' }) + @IsOptional() + @IsString() + @IsNotEmpty() + existingThreeDModelUrl?: string; +} diff --git a/src/modules/catalog/dto/filter-product-reviews.dto.ts b/src/modules/catalog/dto/filter-product-reviews.dto.ts new file mode 100644 index 00000000..17bc5ec8 --- /dev/null +++ b/src/modules/catalog/dto/filter-product-reviews.dto.ts @@ -0,0 +1,40 @@ +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsOptional, + IsUUID, + Min, +} from 'class-validator'; + +export class FilterProductReviewsDto { + @IsOptional() + @IsUUID() + productId?: string; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isApproved?: boolean; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isPinned?: boolean; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + limit?: number = 20; +} diff --git a/src/modules/catalog/dto/filter-products.dto.ts b/src/modules/catalog/dto/filter-products.dto.ts new file mode 100644 index 00000000..c51b8dd4 --- /dev/null +++ b/src/modules/catalog/dto/filter-products.dto.ts @@ -0,0 +1,71 @@ +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsObject, + IsOptional, + IsString, + IsUUID, + Min, +} from 'class-validator'; +import { parseJsonValue } from '../../../common/utils/json-transform.util'; +import { ProductStatus } from '../enums/product-status.enum'; +import { ProductType } from '../enums/product-type.enum'; + +export class FilterProductsDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(ProductType) + type?: ProductType; + + @IsOptional() + @IsEnum(ProductStatus) + status?: ProductStatus; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsUUID() + brandId?: string; + + @IsOptional() + @IsString() + brand?: string; + + @IsOptional() + @Transform(parseJsonValue) + @IsObject() + attributes?: Record; + + @IsOptional() + @Transform(parseJsonValue) + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + featured?: boolean; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + limit?: number = 20; +} diff --git a/src/modules/catalog/dto/moderate-product-review.dto.ts b/src/modules/catalog/dto/moderate-product-review.dto.ts new file mode 100644 index 00000000..31e95ccd --- /dev/null +++ b/src/modules/catalog/dto/moderate-product-review.dto.ts @@ -0,0 +1,21 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class ModerateProductReviewDto { + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isApproved?: boolean; + + @ApiPropertyOptional({ example: false }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isPinned?: boolean; +} diff --git a/src/modules/catalog/dto/product-attribute-input.dto.ts b/src/modules/catalog/dto/product-attribute-input.dto.ts new file mode 100644 index 00000000..914c9e36 --- /dev/null +++ b/src/modules/catalog/dto/product-attribute-input.dto.ts @@ -0,0 +1,123 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; +import { parseJsonValue } from '../../../common/utils/json-transform.util'; +import { AttributeDataType } from '../enums/attribute-data-type.enum'; + +export class ProductAttributeInputDto { + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + attributeId?: string; + + @ApiPropertyOptional({ example: 'Weight' }) + @IsOptional() + @IsString() + @MaxLength(120) + name?: string; + + @ApiPropertyOptional({ example: 'weight' }) + @IsOptional() + @IsString() + @MaxLength(140) + slug?: string; + + @ApiPropertyOptional({ enum: AttributeDataType }) + @IsOptional() + @IsEnum(AttributeDataType) + dataType?: AttributeDataType; + + @ApiPropertyOptional({ example: 'kg' }) + @IsOptional() + @IsString() + @MaxLength(50) + unit?: string; + + @ApiPropertyOptional({ type: [String], example: ['Red', 'Blue'] }) + @IsOptional() + @Transform(parseJsonValue) + @IsArray() + @IsString({ each: true }) + options?: string[]; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isFilterable?: boolean; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isVisible?: boolean; + + @ApiPropertyOptional({ example: '1.25' }) + @IsOptional() + @IsString() + defaultValueText?: string; + + @ApiPropertyOptional({ example: 1.25 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + defaultValueNumber?: number; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + defaultValueBoolean?: boolean; + + @ApiPropertyOptional({ example: ['6305', '6306'] }) + @IsOptional() + @Transform(parseJsonValue) + @IsObject() + defaultValueJson?: Record | string[]; + + @ApiPropertyOptional({ example: '1.40' }) + @IsOptional() + @IsString() + valueText?: string; + + @ApiPropertyOptional({ example: 1.4 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + valueNumber?: number; + + @ApiPropertyOptional({ example: false }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + valueBoolean?: boolean; + + @ApiPropertyOptional({ example: ['2RS', 'ZZ'] }) + @IsOptional() + @Transform(parseJsonValue) + valueJson?: Record | string[]; + + @ApiPropertyOptional({ example: 'g' }) + @IsOptional() + @IsString() + @MaxLength(50) + overrideUnit?: string; +} diff --git a/src/modules/catalog/dto/product-meta.dto.ts b/src/modules/catalog/dto/product-meta.dto.ts new file mode 100644 index 00000000..f411c301 --- /dev/null +++ b/src/modules/catalog/dto/product-meta.dto.ts @@ -0,0 +1,45 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class ProductMetaDto { + @ApiPropertyOptional({ example: 'Industrial bearing for motors and gearboxes' }) + @IsOptional() + @IsString() + @MaxLength(320) + shortDescription?: string; + + @ApiPropertyOptional({ example: 'Full product description in HTML or plain text' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ example: 'SKF 6006-2RS | Buy Industrial Bearing' }) + @IsOptional() + @IsString() + @MaxLength(160) + metaTitle?: string; + + @ApiPropertyOptional({ example: 'Buy SKF 6006-2RS with verified specs and fast delivery.' }) + @IsOptional() + @IsString() + @MaxLength(320) + metaDescription?: string; + + @ApiPropertyOptional({ example: 'SKF 6006-2RS Deep Groove Bearing' }) + @IsOptional() + @IsString() + @MaxLength(160) + shareTitle?: string; + + @ApiPropertyOptional({ example: 'Industrial bearing with verified specs and fast delivery.' }) + @IsOptional() + @IsString() + @MaxLength(320) + shareDescription?: string; + + @ApiPropertyOptional({ example: 'https://cdn.example.com/products/share.jpg' }) + @IsOptional() + @IsString() + @MaxLength(500) + shareImageUrl?: string; +} diff --git a/src/modules/catalog/dto/update-attribute-definition.dto.ts b/src/modules/catalog/dto/update-attribute-definition.dto.ts new file mode 100644 index 00000000..20d44579 --- /dev/null +++ b/src/modules/catalog/dto/update-attribute-definition.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateAttributeDefinitionDto } from './create-attribute-definition.dto'; + +export class UpdateAttributeDefinitionDto extends PartialType( + CreateAttributeDefinitionDto, +) {} diff --git a/src/modules/catalog/dto/update-brand.dto.ts b/src/modules/catalog/dto/update-brand.dto.ts new file mode 100644 index 00000000..db19aeac --- /dev/null +++ b/src/modules/catalog/dto/update-brand.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateBrandDto } from './create-brand.dto'; + +export class UpdateBrandDto extends PartialType(CreateBrandDto) {} diff --git a/src/modules/catalog/dto/update-category.dto.ts b/src/modules/catalog/dto/update-category.dto.ts new file mode 100644 index 00000000..d713b9b9 --- /dev/null +++ b/src/modules/catalog/dto/update-category.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCategoryDto } from './create-category.dto'; + +export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {} diff --git a/src/modules/catalog/dto/update-product.dto.ts b/src/modules/catalog/dto/update-product.dto.ts new file mode 100644 index 00000000..87b9d7f3 --- /dev/null +++ b/src/modules/catalog/dto/update-product.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateProductDto } from './create-product.dto'; + +export class UpdateProductDto extends PartialType(CreateProductDto) {} diff --git a/src/modules/catalog/entities/attribute-definition.entity.ts b/src/modules/catalog/entities/attribute-definition.entity.ts new file mode 100644 index 00000000..72feeedd --- /dev/null +++ b/src/modules/catalog/entities/attribute-definition.entity.ts @@ -0,0 +1,64 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AttributeDataType } from '../enums/attribute-data-type.enum'; +import { ProductAttributeValue } from './product-attribute-value.entity'; + +@Entity({ name: 'attribute_definitions' }) +export class AttributeDefinition { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 120 }) + name: string; + + @Index() + @Column({ type: 'varchar', length: 140, unique: true }) + slug: string; + + @Column({ + name: 'data_type', + type: 'enum', + enum: AttributeDataType, + }) + dataType: AttributeDataType; + + @Column({ type: 'varchar', length: 50, nullable: true }) + unit?: string | null; + + @Column({ type: 'jsonb', default: () => "'[]'" }) + options: string[]; + + @Column({ name: 'default_value_text', type: 'varchar', length: 255, nullable: true }) + defaultValueText?: string | null; + + @Column({ name: 'default_value_number', type: 'numeric', precision: 12, scale: 3, nullable: true }) + defaultValueNumber?: string | null; + + @Column({ name: 'default_value_boolean', type: 'boolean', nullable: true }) + defaultValueBoolean?: boolean | null; + + @Column({ name: 'default_value_json', type: 'jsonb', nullable: true }) + defaultValueJson?: Record | string[] | null; + + @Column({ name: 'is_filterable', type: 'boolean', default: false }) + isFilterable: boolean; + + @Column({ name: 'is_visible', type: 'boolean', default: true }) + isVisible: boolean; + + @OneToMany(() => ProductAttributeValue, (value) => value.attribute) + values: ProductAttributeValue[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/catalog/entities/brand.entity.ts b/src/modules/catalog/entities/brand.entity.ts new file mode 100644 index 00000000..53aadb3d --- /dev/null +++ b/src/modules/catalog/entities/brand.entity.ts @@ -0,0 +1,40 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ProductType } from '../enums/product-type.enum'; +import { Product } from './product.entity'; + +@Entity({ name: 'brands' }) +export class Brand { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true, length: 150 }) + name: string; + + @Column({ unique: true, length: 180 }) + slug: string; + + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl?: string | null; + + @Column({ + type: 'enum', + enum: ProductType, + }) + type: ProductType; + + @OneToMany(() => Product, (product) => product.brandEntity) + products: Product[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/catalog/entities/category.entity.ts b/src/modules/catalog/entities/category.entity.ts index 3dec105b..a7af9517 100644 --- a/src/modules/catalog/entities/category.entity.ts +++ b/src/modules/catalog/entities/category.entity.ts @@ -2,11 +2,14 @@ import { Column, CreateDateColumn, Entity, + ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { ProductType } from '../enums/product-type.enum'; +import { Product } from './product.entity'; @Entity({ name: 'categories' }) export class Category { @@ -19,6 +22,15 @@ export class Category { @Column({ unique: true, length: 180 }) slug: string; + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl?: string | null; + + @Column({ + type: 'enum', + enum: ProductType, + }) + type: ProductType; + @ManyToOne(() => Category, (category) => category.children, { nullable: true, onDelete: 'SET NULL', @@ -28,6 +40,12 @@ export class Category { @OneToMany(() => Category, (category) => category.parent) children: Category[]; + @OneToMany(() => Product, (product) => product.primaryCategory) + primaryProducts: Product[]; + + @ManyToMany(() => Product, (product) => product.categories) + products: Product[]; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/src/modules/catalog/entities/product-attribute-value.entity.ts b/src/modules/catalog/entities/product-attribute-value.entity.ts new file mode 100644 index 00000000..207e6766 --- /dev/null +++ b/src/modules/catalog/entities/product-attribute-value.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { AttributeDefinition } from './attribute-definition.entity'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_attribute_values' }) +export class ProductAttributeValue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Product, (product) => product.attributeValues, { + onDelete: 'CASCADE', + }) + product: Product; + + @ManyToOne(() => AttributeDefinition, (attribute) => attribute.values, { + eager: true, + onDelete: 'CASCADE', + }) + attribute: AttributeDefinition; + + @Column({ name: 'value_text', type: 'varchar', length: 255, nullable: true }) + valueText?: string | null; + + @Column({ + name: 'value_number', + type: 'numeric', + precision: 12, + scale: 3, + nullable: true, + transformer: { + to: (value?: number | null) => value, + from: (value?: string | null) => + value === null || value === undefined ? null : Number(value), + }, + }) + valueNumber?: number | null; + + @Column({ name: 'value_boolean', type: 'boolean', nullable: true }) + valueBoolean?: boolean | null; + + @Column({ name: 'value_json', type: 'jsonb', nullable: true }) + valueJson?: Record | string[] | null; + + @Column({ name: 'override_unit', type: 'varchar', length: 50, nullable: true }) + overrideUnit?: string | null; +} diff --git a/src/modules/catalog/entities/product-meta.entity.ts b/src/modules/catalog/entities/product-meta.entity.ts new file mode 100644 index 00000000..2e229abe --- /dev/null +++ b/src/modules/catalog/entities/product-meta.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_meta' }) +export class ProductMeta { + @PrimaryGeneratedColumn('uuid') + id: string; + + @OneToOne(() => Product, (product) => product.meta, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Column({ name: 'short_description', type: 'varchar', length: 320, nullable: true }) + shortDescription?: string | null; + + @Column({ type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'meta_title', type: 'varchar', length: 160, nullable: true }) + metaTitle?: string | null; + + @Column({ name: 'meta_description', type: 'varchar', length: 320, nullable: true }) + metaDescription?: string | null; + + @Column({ name: 'share_title', type: 'varchar', length: 160, nullable: true }) + shareTitle?: string | null; + + @Column({ name: 'share_description', type: 'varchar', length: 320, nullable: true }) + shareDescription?: string | null; + + @Column({ name: 'share_image_url', type: 'varchar', length: 500, nullable: true }) + shareImageUrl?: string | null; +} diff --git a/src/modules/catalog/entities/product-review.entity.ts b/src/modules/catalog/entities/product-review.entity.ts new file mode 100644 index 00000000..afbb53dd --- /dev/null +++ b/src/modules/catalog/entities/product-review.entity.ts @@ -0,0 +1,49 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_reviews' }) +export class ProductReview { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @ManyToOne(() => Product, (product) => product.reviews, { + onDelete: 'CASCADE', + }) + product: Product; + + @Column({ type: 'varchar', length: 120 }) + name: string; + + @Column({ type: 'varchar', length: 160, nullable: true }) + email?: string | null; + + @Column({ type: 'int' }) + rating: number; + + @Column({ type: 'varchar', length: 160, nullable: true }) + title?: string | null; + + @Column({ type: 'text' }) + comment: string; + + @Column({ name: 'is_approved', type: 'boolean', default: false }) + isApproved: boolean; + + @Column({ name: 'is_pinned', type: 'boolean', default: false }) + isPinned: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/catalog/entities/product.entity.ts b/src/modules/catalog/entities/product.entity.ts index 49021970..af91f809 100644 --- a/src/modules/catalog/entities/product.entity.ts +++ b/src/modules/catalog/entities/product.entity.ts @@ -3,12 +3,21 @@ import { CreateDateColumn, Entity, Index, + JoinTable, + ManyToMany, ManyToOne, + OneToMany, + OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { ProductStatus } from '../enums/product-status.enum'; import { ProductType } from '../enums/product-type.enum'; +import { Brand } from './brand.entity'; import { Category } from './category.entity'; +import { ProductAttributeValue } from './product-attribute-value.entity'; +import { ProductMeta } from './product-meta.entity'; +import { ProductReview } from './product-review.entity'; @Entity({ name: 'products' }) export class Product { @@ -18,6 +27,14 @@ export class Product { @Column({ unique: true, length: 80 }) sku: string; + @Index() + @Column({ length: 160 }) + title: string; + + @Index() + @Column({ unique: true, length: 180 }) + slug: string; + @Index() @Column({ name: 'technical_code', length: 120 }) technicalCode: string; @@ -25,6 +42,12 @@ export class Product { @Column({ length: 120 }) brand: string; + @ManyToOne(() => Brand, (brand) => brand.products, { + nullable: true, + onDelete: 'SET NULL', + }) + brandEntity?: Brand | null; + @Column({ name: 'base_price_usd', type: 'numeric', @@ -37,23 +60,103 @@ export class Product { }) basePriceUSD: number; + @Column({ + name: 'sale_price_usd', + type: 'numeric', + precision: 12, + scale: 2, + nullable: true, + transformer: { + to: (value?: number | null) => value, + from: (value?: string | null) => + value === null || value === undefined ? null : Number(value), + }, + }) + salePriceUSD?: number | null; + @Column({ type: 'int', default: 0 }) stock: number; + @Column({ type: 'boolean', default: false }) + featured: boolean; + @Column({ type: 'enum', enum: ProductType, }) type: ProductType; - @Column({ name: '3d_model_url', nullable: true, length: 500 }) + @Column({ + type: 'enum', + enum: ProductStatus, + default: ProductStatus.DRAFT, + }) + status: ProductStatus; + + @Column({ type: 'varchar', name: 'main_image_url', nullable: true, length: 500 }) + mainImageUrl?: string | null; + + @Column({ type: 'varchar', name: '3d_model_url', nullable: true, length: 500 }) threeDModelUrl?: string | null; - @Column({ type: 'jsonb', default: () => "'{}'" }) - attributes: Record; + @Column({ type: 'jsonb', name: 'image_gallery_urls', default: () => "'[]'" }) + imageGalleryUrls: string[]; - @ManyToOne(() => Category, { nullable: true, onDelete: 'SET NULL' }) - category?: Category | null; + @Column({ type: 'jsonb', default: () => "'[]'" }) + tags: string[]; + + @Column({ + name: 'average_rating', + type: 'numeric', + precision: 3, + scale: 2, + default: 0, + transformer: { + to: (value: number) => value, + from: (value: string) => Number(value), + }, + }) + averageRating: number; + + @Column({ name: 'reviews_count', type: 'int', default: 0 }) + reviewsCount: number; + + @ManyToOne(() => Category, (category) => category.primaryProducts, { + nullable: true, + onDelete: 'SET NULL', + }) + primaryCategory?: Category | null; + + @ManyToMany(() => Category, (category) => category.products, { + cascade: false, + }) + @JoinTable({ + name: 'product_categories', + joinColumn: { + name: 'product_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'category_id', + referencedColumnName: 'id', + }, + }) + categories: Category[]; + + @OneToOne(() => ProductMeta, (meta) => meta.product, { + cascade: true, + eager: true, + }) + meta: ProductMeta; + + @OneToMany(() => ProductAttributeValue, (attributeValue) => attributeValue.product, { + cascade: true, + eager: true, + }) + attributeValues: ProductAttributeValue[]; + + @OneToMany(() => ProductReview, (review) => review.product) + reviews: ProductReview[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/src/modules/catalog/enums/attribute-data-type.enum.ts b/src/modules/catalog/enums/attribute-data-type.enum.ts new file mode 100644 index 00000000..1bb5558a --- /dev/null +++ b/src/modules/catalog/enums/attribute-data-type.enum.ts @@ -0,0 +1,8 @@ +export enum AttributeDataType { + TEXT = 'text', + NUMBER = 'number', + BOOLEAN = 'boolean', + SELECT = 'select', + MULTISELECT = 'multiselect', + JSON = 'json', +} diff --git a/src/modules/catalog/enums/product-status.enum.ts b/src/modules/catalog/enums/product-status.enum.ts new file mode 100644 index 00000000..e3989e8c --- /dev/null +++ b/src/modules/catalog/enums/product-status.enum.ts @@ -0,0 +1,5 @@ +export enum ProductStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + ARCHIVED = 'archived', +} diff --git a/src/modules/catalog/products.controller.ts b/src/modules/catalog/products.controller.ts new file mode 100644 index 00000000..6f99ccea --- /dev/null +++ b/src/modules/catalog/products.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateProductReviewDto } from './dto/create-product-review.dto'; +import { FilterProductsDto } from './dto/filter-products.dto'; +import { ProductsService } from './products.service'; + +@ApiTags('Products') +@Controller('products') +export class ProductsController { + constructor(private readonly productsService: ProductsService) {} + + @Get() + @ApiOperation({ summary: 'List published products for storefront' }) + findAll(@Query() filters: FilterProductsDto) { + return this.productsService.findPublic(filters); + } + + @Get('slug/:slug') + @ApiOperation({ summary: 'Get one published product by slug' }) + findBySlug(@Param('slug') slug: string) { + return this.productsService.findPublicOneBySlug(slug); + } + + @Get(':id/reviews') + @ApiOperation({ summary: 'List approved reviews for a product' }) + findApprovedReviews(@Param('id') id: string) { + return this.productsService.findApprovedReviews(id); + } + + @Post(':id/reviews') + @ApiOperation({ summary: 'Submit a new product review' }) + @ApiBody({ type: CreateProductReviewDto }) + createReview(@Param('id') id: string, @Body() dto: CreateProductReviewDto) { + return this.productsService.createReview(id, dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one published product with approved reviews summary' }) + findOne(@Param('id') id: string) { + return this.productsService.findPublicOne(id); + } +} diff --git a/src/modules/catalog/products.service.ts b/src/modules/catalog/products.service.ts new file mode 100644 index 00000000..5b40893f --- /dev/null +++ b/src/modules/catalog/products.service.ts @@ -0,0 +1,899 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { SelectQueryBuilder, Repository } from 'typeorm'; +import { StorageService } from '../storage/storage.service'; +import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto'; +import { CreateProductDto } from './dto/create-product.dto'; +import { CreateProductReviewDto } from './dto/create-product-review.dto'; +import { FilterProductReviewsDto } from './dto/filter-product-reviews.dto'; +import { FilterProductsDto } from './dto/filter-products.dto'; +import { ModerateProductReviewDto } from './dto/moderate-product-review.dto'; +import { ProductAttributeInputDto } from './dto/product-attribute-input.dto'; +import { ProductMetaDto } from './dto/product-meta.dto'; +import { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto'; +import { UpdateProductDto } from './dto/update-product.dto'; +import { AttributeDefinition } from './entities/attribute-definition.entity'; +import { Brand } from './entities/brand.entity'; +import { Category } from './entities/category.entity'; +import { ProductAttributeValue } from './entities/product-attribute-value.entity'; +import { ProductMeta } from './entities/product-meta.entity'; +import { Product } from './entities/product.entity'; +import { ProductReview } from './entities/product-review.entity'; +import { AttributeDataType } from './enums/attribute-data-type.enum'; +import { ProductStatus } from './enums/product-status.enum'; + +@Injectable() +export class ProductsService { + constructor( + @InjectRepository(Product) + private readonly productsRepository: Repository, + @InjectRepository(ProductMeta) + private readonly productMetaRepository: Repository, + @InjectRepository(ProductAttributeValue) + private readonly productAttributeValuesRepository: Repository, + @InjectRepository(AttributeDefinition) + private readonly attributeDefinitionsRepository: Repository, + @InjectRepository(ProductReview) + private readonly productReviewsRepository: Repository, + @InjectRepository(Category) + private readonly categoriesRepository: Repository, + @InjectRepository(Brand) + private readonly brandsRepository: Repository, + private readonly storageService: StorageService, + ) {} + + async create( + dto: CreateProductDto, + files?: { + mainImage?: Express.Multer.File[]; + images?: Express.Multer.File[]; + model3d?: Express.Multer.File[]; + }, + ) { + const { primaryCategory, categories } = await this.resolveCategories( + dto.primaryCategoryId, + dto.categoryIds, + dto.type, + ); + const brandEntity = await this.resolveBrand(dto.brandId, dto.type); + const [mainImage, imageGallery, modelFile] = await Promise.all([ + files?.mainImage?.[0] + ? this.storageService.uploadPublicFile(files.mainImage[0], 'products/main') + : Promise.resolve(null), + files?.images?.length + ? Promise.all( + files.images.map((file) => + this.storageService.uploadPublicFile(file, 'products/gallery'), + ), + ) + : Promise.resolve([]), + files?.model3d?.[0] + ? this.storageService.uploadPublicFile(files.model3d[0], 'products/models') + : Promise.resolve(null), + ]); + + const product = await this.productsRepository.save( + this.productsRepository.create({ + sku: dto.sku, + title: dto.title, + slug: dto.slug, + technicalCode: dto.technicalCode, + brand: brandEntity?.name ?? dto.brand ?? '', + brandEntity, + basePriceUSD: dto.basePriceUSD, + salePriceUSD: dto.salePriceUSD ?? null, + stock: dto.stock, + featured: dto.featured ?? false, + type: dto.type, + status: dto.status ?? ProductStatus.DRAFT, + primaryCategory, + categories, + tags: dto.tags ?? [], + mainImageUrl: mainImage?.url ?? dto.existingMainImageUrl ?? null, + threeDModelUrl: modelFile?.url ?? dto.existingThreeDModelUrl ?? null, + imageGalleryUrls: + imageGallery.length > 0 + ? imageGallery.map((item) => item.url) + : (dto.existingGalleryUrls ?? []), + averageRating: 0, + reviewsCount: 0, + }), + ); + + product.meta = await this.saveMeta(product, dto.meta); + product.attributeValues = await this.syncAttributeValues( + product, + dto.attributes ?? [], + ); + + return this.serializeProduct(await this.findOneById(product.id)); + } + + async findPublic(filters: FilterProductsDto) { + return this.findAll(filters, false); + } + + async findAdmin(filters: FilterProductsDto) { + return this.findAll(filters, true); + } + + async checkSlugAvailability(slug: string, excludeId?: string) { + const query = this.productsRepository + .createQueryBuilder('product') + .where('LOWER(product.slug) = LOWER(:slug)', { slug }); + + if (excludeId) { + query.andWhere('product.id != :excludeId', { excludeId }); + } + + const existing = await query.getOne(); + + return !existing; + } + + async findPublicOne(id: string) { + const product = await this.productsRepository.findOne({ + where: { id, status: ProductStatus.PUBLISHED }, + relations: { + primaryCategory: true, + categories: true, + brandEntity: true, + meta: true, + attributeValues: { attribute: true }, + }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const approvedReviews = await this.findApprovedReviewsInternal(product.id, 10); + + return { + ...this.serializeProduct(product), + approvedReviews, + }; + } + + async findPublicOneBySlug(slug: string) { + const product = await this.productsRepository.findOne({ + where: { slug, status: ProductStatus.PUBLISHED }, + relations: { + primaryCategory: true, + categories: true, + brandEntity: true, + meta: true, + attributeValues: { attribute: true }, + }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const approvedReviews = await this.findApprovedReviewsInternal(product.id, 10); + + return { + ...this.serializeProduct(product), + approvedReviews, + }; + } + + async findAdminOne(id: string) { + return this.serializeProduct(await this.findOneById(id)); + } + + async createReview(productId: string, dto: CreateProductReviewDto) { + const product = await this.ensurePublishedProduct(productId); + const review = this.productReviewsRepository.create({ + product, + name: dto.name, + email: dto.email ?? null, + rating: dto.rating, + title: dto.title ?? null, + comment: dto.comment, + isApproved: false, + isPinned: false, + }); + + await this.productReviewsRepository.save(review); + + return { + message: 'Review submitted successfully and is pending approval', + reviewId: review.id, + }; + } + + async findApprovedReviews(productId: string) { + await this.ensurePublishedProduct(productId); + return this.findApprovedReviewsInternal(productId); + } + + async findAdminReviews(filters: FilterProductReviewsDto) { + const page = filters.page ?? 1; + const limit = filters.limit ?? 20; + + const query = this.productReviewsRepository + .createQueryBuilder('review') + .leftJoinAndSelect('review.product', 'product') + .orderBy('review.isPinned', 'DESC') + .addOrderBy('review.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + if (filters.productId) { + query.andWhere('product.id = :productId', { productId: filters.productId }); + } + + if (filters.isApproved !== undefined) { + query.andWhere('review.is_approved = :isApproved', { + isApproved: filters.isApproved, + }); + } + + if (filters.isPinned !== undefined) { + query.andWhere('review.is_pinned = :isPinned', { + isPinned: filters.isPinned, + }); + } + + const [items, total] = await query.getManyAndCount(); + + return { + items, + meta: { + total, + page, + limit, + }, + }; + } + + async updateReview(reviewId: string, dto: ModerateProductReviewDto) { + const review = await this.productReviewsRepository.findOne({ + where: { id: reviewId }, + relations: { product: true }, + }); + + if (!review) { + throw new NotFoundException('Review not found'); + } + + Object.assign(review, { + isApproved: dto.isApproved ?? review.isApproved, + isPinned: dto.isPinned ?? review.isPinned, + }); + + const savedReview = await this.productReviewsRepository.save(review); + await this.refreshReviewStats(review.product.id); + + return savedReview; + } + + async removeReview(reviewId: string) { + const review = await this.productReviewsRepository.findOne({ + where: { id: reviewId }, + relations: { product: true }, + }); + + if (!review) { + throw new NotFoundException('Review not found'); + } + + const productId = review.product.id; + await this.productReviewsRepository.remove(review); + await this.refreshReviewStats(productId); + + return { message: 'Review deleted successfully' }; + } + + async update( + id: string, + dto: UpdateProductDto, + files?: { + mainImage?: Express.Multer.File[]; + images?: Express.Multer.File[]; + model3d?: Express.Multer.File[]; + }, + ) { + const product = await this.findOneById(id); + const nextType = dto.type ?? product.type; + const { primaryCategory, categories } = await this.resolveCategories( + dto.primaryCategoryId !== undefined + ? dto.primaryCategoryId + : product.primaryCategory?.id, + dto.categoryIds !== undefined + ? dto.categoryIds + : product.categories?.map((item) => item.id), + nextType, + ); + const brandId = + dto.brandId !== undefined ? dto.brandId : product.brandEntity?.id; + const brandEntity = await this.resolveBrand(brandId, nextType); + + if (files?.mainImage?.[0]) { + const upload = await this.storageService.uploadPublicFile( + files.mainImage[0], + 'products/main', + ); + await this.replaceFile(product.mainImageUrl, upload.url); + product.mainImageUrl = upload.url; + } else if (dto.existingMainImageUrl !== undefined) { + await this.replaceFile(product.mainImageUrl, dto.existingMainImageUrl || null); + product.mainImageUrl = dto.existingMainImageUrl || null; + } + + if (files?.images?.length) { + const galleryUploads = await Promise.all( + files.images.map((file) => + this.storageService.uploadPublicFile(file, 'products/gallery'), + ), + ); + await this.deleteGallery(product.imageGalleryUrls); + product.imageGalleryUrls = galleryUploads.map((item) => item.url); + } else if (dto.existingGalleryUrls !== undefined) { + const removedUrls = (product.imageGalleryUrls ?? []).filter( + (url) => !dto.existingGalleryUrls?.includes(url), + ); + await this.deleteGallery(removedUrls); + product.imageGalleryUrls = dto.existingGalleryUrls; + } + + if (files?.model3d?.[0]) { + const modelUpload = await this.storageService.uploadPublicFile( + files.model3d[0], + 'products/models', + ); + await this.replaceFile(product.threeDModelUrl, modelUpload.url); + product.threeDModelUrl = modelUpload.url; + } else if (dto.existingThreeDModelUrl !== undefined) { + await this.replaceFile( + product.threeDModelUrl, + dto.existingThreeDModelUrl || null, + ); + product.threeDModelUrl = dto.existingThreeDModelUrl || null; + } + + Object.assign(product, { + sku: dto.sku ?? product.sku, + title: dto.title ?? product.title, + slug: dto.slug ?? product.slug, + technicalCode: dto.technicalCode ?? product.technicalCode, + brand: brandEntity?.name ?? dto.brand ?? product.brand, + brandEntity, + basePriceUSD: dto.basePriceUSD ?? product.basePriceUSD, + salePriceUSD: dto.salePriceUSD ?? product.salePriceUSD, + stock: dto.stock ?? product.stock, + featured: dto.featured ?? product.featured, + type: nextType, + status: dto.status ?? product.status, + primaryCategory, + categories, + tags: dto.tags ?? product.tags, + }); + + await this.productsRepository.save(product); + product.meta = await this.saveMeta(product, dto.meta); + + if (dto.attributes !== undefined) { + product.attributeValues = await this.syncAttributeValues(product, dto.attributes); + } + + return this.serializeProduct(await this.findOneById(product.id)); + } + + async remove(id: string) { + const product = await this.findOneById(id); + await this.deleteGallery(product.imageGalleryUrls); + await this.deleteModel(product.threeDModelUrl); + await this.storageService.deletePublicFileByUrl(product.mainImageUrl); + await this.storageService.deletePublicFileByUrl(product.meta?.shareImageUrl); + await this.productsRepository.remove(product); + + return { message: 'Product deleted successfully' }; + } + + async listAttributeDefinitions() { + return this.attributeDefinitionsRepository.find({ + order: { + isVisible: 'DESC', + name: 'ASC', + }, + }); + } + + async createAttributeDefinition(dto: CreateAttributeDefinitionDto) { + const existing = await this.attributeDefinitionsRepository.findOne({ + where: [{ slug: dto.slug }, { name: dto.name }], + }); + if (existing) { + throw new BadRequestException('Attribute definition already exists'); + } + + return this.attributeDefinitionsRepository.save( + this.attributeDefinitionsRepository.create({ + name: dto.name, + slug: dto.slug, + dataType: dto.dataType, + unit: dto.unit ?? null, + options: dto.options ?? [], + isFilterable: dto.isFilterable ?? false, + isVisible: dto.isVisible ?? true, + }), + ); + } + + async updateAttributeDefinition(id: string, dto: UpdateAttributeDefinitionDto) { + const definition = await this.attributeDefinitionsRepository.findOne({ + where: { id }, + }); + + if (!definition) { + throw new NotFoundException('Attribute definition not found'); + } + + Object.assign(definition, { + name: dto.name ?? definition.name, + slug: dto.slug ?? definition.slug, + dataType: dto.dataType ?? definition.dataType, + unit: dto.unit ?? definition.unit, + options: dto.options ?? definition.options, + isFilterable: dto.isFilterable ?? definition.isFilterable, + isVisible: dto.isVisible ?? definition.isVisible, + }); + + return this.attributeDefinitionsRepository.save(definition); + } + + async removeAttributeDefinition(id: string) { + const definition = await this.attributeDefinitionsRepository.findOne({ + where: { id }, + }); + + if (!definition) { + throw new NotFoundException('Attribute definition not found'); + } + + await this.attributeDefinitionsRepository.remove(definition); + return { message: 'Attribute definition deleted successfully' }; + } + + private async findAll(filters: FilterProductsDto, includeUnpublished: boolean) { + const page = filters.page ?? 1; + const limit = filters.limit ?? 20; + + const query = this.productsRepository + .createQueryBuilder('product') + .leftJoinAndSelect('product.primaryCategory', 'primaryCategory') + .leftJoinAndSelect('product.categories', 'categories') + .leftJoinAndSelect('product.brandEntity', 'brandEntity') + .leftJoinAndSelect('product.meta', 'meta') + .orderBy('product.featured', 'DESC') + .addOrderBy('product.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + if (!includeUnpublished) { + query.andWhere('product.status = :publishedStatus', { + publishedStatus: ProductStatus.PUBLISHED, + }); + } else if (filters.status) { + query.andWhere('product.status = :status', { status: filters.status }); + } + + if (filters.search) { + query.andWhere( + '(product.technical_code ILIKE :search OR product.title ILIKE :search OR product.slug ILIKE :search OR product.brand ILIKE :search)', + { + search: `%${filters.search}%`, + }, + ); + } + + if (filters.type) { + query.andWhere('product.type = :type', { type: filters.type }); + } + + if (filters.categoryId) { + query.andWhere( + '(primaryCategory.id = :categoryId OR categories.id = :categoryId)', + { categoryId: filters.categoryId }, + ); + } + + if (filters.brand) { + query.andWhere('product.brand ILIKE :brand', { brand: `%${filters.brand}%` }); + } + + if (filters.brandId) { + query.andWhere('brandEntity.id = :brandId', { brandId: filters.brandId }); + } + + if (filters.tags?.length) { + query.andWhere('product.tags @> :tags', { + tags: JSON.stringify(filters.tags), + }); + } + + if (filters.featured !== undefined) { + query.andWhere('product.featured = :featured', { featured: filters.featured }); + } + + this.applyAttributeFilters(query, filters.attributes); + + const [items, total] = await query.getManyAndCount(); + + return { + items: items.map((item) => this.serializeProduct(item, false)), + meta: { + total, + page, + limit, + }, + }; + } + + private applyAttributeFilters( + query: SelectQueryBuilder, + filters?: Record, + ) { + if (!filters || Object.keys(filters).length === 0) { + return; + } + + Object.entries(filters).forEach(([slug, value], index) => { + const valueParam = `attrValue${index}`; + const slugParam = `attrSlug${index}`; + query.andWhere( + `EXISTS ( + SELECT 1 + FROM product_attribute_values pav + INNER JOIN attribute_definitions ad ON ad.id = pav.attributeId + WHERE pav.productId = product.id + AND ad.slug = :${slugParam} + AND ( + pav.value_text = :${valueParam} + OR CAST(pav.value_number AS TEXT) = :${valueParam} + OR CAST(pav.value_boolean AS TEXT) = :${valueParam} + OR CAST(pav.value_json AS TEXT) ILIKE :${valueParam}Like + ) + )`, + { + [slugParam]: slug, + [valueParam]: String(value), + [`${valueParam}Like`]: `%${String(value)}%`, + }, + ); + }); + } + + private async findOneById(id: string) { + const product = await this.productsRepository.findOne({ + where: { id }, + relations: { + primaryCategory: true, + categories: true, + brandEntity: true, + meta: true, + attributeValues: { attribute: true }, + }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + return product; + } + + private async resolveCategories( + primaryCategoryId: string | undefined, + categoryIds: string[] | undefined, + type: Product['type'], + ) { + const normalizedIds = Array.from( + new Set( + [primaryCategoryId, ...(categoryIds ?? [])].filter( + (value): value is string => Boolean(value), + ), + ), + ); + + if (normalizedIds.length === 0) { + return { + primaryCategory: null, + categories: [], + }; + } + + const categories = await this.categoriesRepository.find({ + where: normalizedIds.map((id) => ({ id })), + }); + + if (categories.length !== normalizedIds.length) { + throw new NotFoundException('One or more categories not found'); + } + + for (const category of categories) { + if (category.type !== type) { + throw new BadRequestException( + 'Product type must match assigned category type', + ); + } + } + + const primaryCategory = primaryCategoryId + ? categories.find((item) => item.id === primaryCategoryId) ?? null + : categories[0] ?? null; + + return { + primaryCategory, + categories, + }; + } + + private async resolveBrand(brandId: string | undefined, type: Product['type']) { + if (!brandId) { + return null; + } + + const brand = await this.brandsRepository.findOne({ + where: { id: brandId }, + }); + if (!brand) { + throw new NotFoundException('Brand not found'); + } + + if (brand.type !== type) { + throw new BadRequestException('Product type must match assigned brand type'); + } + + return brand; + } + + private async ensurePublishedProduct(productId: string) { + const product = await this.productsRepository.findOne({ + where: { id: productId, status: ProductStatus.PUBLISHED }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + return product; + } + + private async findApprovedReviewsInternal(productId: string, take?: number) { + return this.productReviewsRepository.find({ + where: { + product: { id: productId }, + isApproved: true, + }, + order: { + isPinned: 'DESC', + createdAt: 'DESC', + }, + ...(take ? { take } : {}), + }); + } + + private async refreshReviewStats(productId: string) { + const reviews = await this.productReviewsRepository.find({ + where: { + product: { id: productId }, + isApproved: true, + }, + select: { + id: true, + rating: true, + }, + }); + + const reviewsCount = reviews.length; + const averageRating = + reviewsCount === 0 + ? 0 + : Number( + ( + reviews.reduce((sum, review) => sum + review.rating, 0) / reviewsCount + ).toFixed(2), + ); + + await this.productsRepository.update(productId, { + reviewsCount, + averageRating, + }); + } + + private async saveMeta(product: Product, dto?: ProductMetaDto) { + const existing = + product.meta ?? + (await this.productMetaRepository.findOne({ + where: { product: { id: product.id } }, + relations: { product: true }, + })); + + const meta = existing ?? this.productMetaRepository.create({ product }); + + Object.assign(meta, { + product, + shortDescription: dto?.shortDescription ?? meta.shortDescription ?? null, + description: dto?.description ?? meta.description ?? null, + metaTitle: dto?.metaTitle ?? meta.metaTitle ?? null, + metaDescription: dto?.metaDescription ?? meta.metaDescription ?? null, + shareTitle: dto?.shareTitle ?? meta.shareTitle ?? null, + shareDescription: dto?.shareDescription ?? meta.shareDescription ?? null, + shareImageUrl: dto?.shareImageUrl ?? meta.shareImageUrl ?? null, + }); + + return this.productMetaRepository.save(meta); + } + + private async syncAttributeValues( + product: Product, + inputs: ProductAttributeInputDto[], + ) { + await this.productAttributeValuesRepository.delete({ + product: { id: product.id }, + }); + + const results: ProductAttributeValue[] = []; + for (const input of inputs) { + const attribute = await this.resolveAttributeDefinition(input); + const value = this.productAttributeValuesRepository.create({ + product, + attribute, + valueText: input.valueText ?? null, + valueNumber: input.valueNumber ?? null, + valueBoolean: input.valueBoolean ?? null, + valueJson: input.valueJson ?? null, + overrideUnit: input.overrideUnit ?? null, + }); + results.push(await this.productAttributeValuesRepository.save(value)); + } + + return results; + } + + private async resolveAttributeDefinition(input: ProductAttributeInputDto) { + if (input.attributeId) { + const existing = await this.attributeDefinitionsRepository.findOne({ + where: { id: input.attributeId }, + }); + if (!existing) { + throw new NotFoundException('Attribute definition not found'); + } + + return existing; + } + + if (!input.slug || !input.name || !input.dataType) { + throw new BadRequestException( + 'Attribute input requires attributeId or { slug, name, dataType }', + ); + } + + const existingBySlug = await this.attributeDefinitionsRepository.findOne({ + where: { slug: input.slug }, + }); + if (existingBySlug) { + return existingBySlug; + } + + return this.attributeDefinitionsRepository.save( + this.attributeDefinitionsRepository.create({ + name: input.name, + slug: input.slug, + dataType: input.dataType, + unit: input.unit ?? null, + options: input.options ?? [], + defaultValueText: input.defaultValueText ?? null, + defaultValueNumber: + input.defaultValueNumber !== undefined ? String(input.defaultValueNumber) : null, + defaultValueBoolean: input.defaultValueBoolean ?? null, + defaultValueJson: input.defaultValueJson ?? null, + isFilterable: input.isFilterable ?? false, + isVisible: input.isVisible ?? true, + }), + ); + } + + private serializeProduct(product: Product, includeMeta = true) { + const meta = product.meta ?? null; + const shortDescription = meta?.shortDescription ?? null; + const share = { + title: meta?.shareTitle ?? product.title, + description: meta?.shareDescription ?? shortDescription, + imageUrl: meta?.shareImageUrl ?? product.mainImageUrl, + }; + const seo = includeMeta + ? { + title: meta?.metaTitle ?? product.title, + description: meta?.metaDescription ?? shortDescription, + } + : undefined; + + return { + ...product, + brandInfo: product.brandEntity + ? { + id: product.brandEntity.id, + name: product.brandEntity.name, + slug: product.brandEntity.slug, + imageUrl: product.brandEntity.imageUrl, + type: product.brandEntity.type, + } + : null, + primaryCategory: product.primaryCategory + ? { + id: product.primaryCategory.id, + name: product.primaryCategory.name, + slug: product.primaryCategory.slug, + imageUrl: product.primaryCategory.imageUrl, + type: product.primaryCategory.type, + } + : null, + categories: (product.categories ?? []).map((category) => ({ + id: category.id, + name: category.name, + slug: category.slug, + imageUrl: category.imageUrl, + type: category.type, + })), + ...(includeMeta + ? { + meta: { + shortDescription, + description: meta?.description ?? null, + seo, + share, + }, + } + : {}), + attributes: (product.attributeValues ?? []).map((item) => ({ + id: item.id, + attributeId: item.attribute?.id, + name: item.attribute?.name, + slug: item.attribute?.slug, + dataType: item.attribute?.dataType, + unit: item.overrideUnit ?? item.attribute?.unit ?? null, + options: item.attribute?.options ?? [], + isFilterable: item.attribute?.isFilterable ?? false, + isVisible: item.attribute?.isVisible ?? true, + valueText: item.valueText ?? item.attribute?.defaultValueText ?? null, + valueNumber: + item.valueNumber ?? + (item.attribute?.defaultValueNumber !== null && + item.attribute?.defaultValueNumber !== undefined + ? Number(item.attribute.defaultValueNumber) + : null), + valueBoolean: + item.valueBoolean ?? item.attribute?.defaultValueBoolean ?? null, + valueJson: item.valueJson ?? item.attribute?.defaultValueJson ?? null, + })), + }; + } + + private async deleteGallery(imageUrls: string[]) { + await Promise.all( + (imageUrls ?? []).map((url) => this.storageService.deletePublicFileByUrl(url)), + ); + } + + private async deleteModel(modelUrl?: string | null) { + await this.storageService.deletePublicFileByUrl(modelUrl); + } + + private async replaceFile( + currentUrl?: string | null, + nextUrl?: string | null, + ): Promise { + if (currentUrl && currentUrl !== nextUrl) { + await this.storageService.deletePublicFileByUrl(currentUrl); + } + } +} diff --git a/src/modules/media/dto/filter-media-assets.dto.ts b/src/modules/media/dto/filter-media-assets.dto.ts new file mode 100644 index 00000000..0a5d1e3f --- /dev/null +++ b/src/modules/media/dto/filter-media-assets.dto.ts @@ -0,0 +1,35 @@ +import { Transform } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { MediaSection } from '../enums/media-section.enum'; + +export class FilterMediaAssetsDto { + @IsOptional() + @IsEnum(MediaSection) + section?: MediaSection; + + @IsOptional() + @IsString() + folder?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + limit?: number = 24; +} diff --git a/src/modules/media/dto/update-media-asset.dto.ts b/src/modules/media/dto/update-media-asset.dto.ts new file mode 100644 index 00000000..a2301be7 --- /dev/null +++ b/src/modules/media/dto/update-media-asset.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { UploadMediaDto } from './upload-media.dto'; + +export class UpdateMediaAssetDto extends PartialType(UploadMediaDto) {} diff --git a/src/modules/media/dto/upload-media.dto.ts b/src/modules/media/dto/upload-media.dto.ts new file mode 100644 index 00000000..5167a6ee --- /dev/null +++ b/src/modules/media/dto/upload-media.dto.ts @@ -0,0 +1,56 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsObject, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { parseJsonValue } from '../../../common/utils/json-transform.util'; +import { MediaSection } from '../enums/media-section.enum'; + +export class UploadMediaDto { + @ApiPropertyOptional({ enum: MediaSection, example: MediaSection.GALLERY }) + @IsOptional() + @IsEnum(MediaSection) + section?: MediaSection; + + @ApiPropertyOptional({ example: 'products/bearings' }) + @IsOptional() + @IsString() + @MaxLength(160) + folder?: string; + + @ApiPropertyOptional({ example: 'SKF hero media' }) + @IsOptional() + @IsString() + @MaxLength(255) + title?: string; + + @ApiPropertyOptional({ example: 'SKF product image' }) + @IsOptional() + @IsString() + @MaxLength(255) + alt?: string; + + @ApiPropertyOptional({ example: 'Primary library item for product page' }) + @IsOptional() + @IsString() + caption?: string; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true', + ) + @IsBoolean() + isPublic?: boolean; + + @ApiPropertyOptional({ example: { source: 'admin-panel' } }) + @IsOptional() + @Transform(parseJsonValue) + @IsObject() + metadata?: Record; +} diff --git a/src/modules/media/entities/media-asset.entity.ts b/src/modules/media/entities/media-asset.entity.ts new file mode 100644 index 00000000..9e6c6e5a --- /dev/null +++ b/src/modules/media/entities/media-asset.entity.ts @@ -0,0 +1,68 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { MediaSection } from '../enums/media-section.enum'; + +@Entity({ name: 'media_assets' }) +export class MediaAsset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ + type: 'enum', + enum: MediaSection, + }) + section: MediaSection; + + @Index() + @Column({ type: 'varchar', length: 160, default: 'root' }) + folder: string; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName: string; + + @Column({ name: 'object_name', type: 'varchar', length: 500, unique: true }) + objectName: string; + + @Column({ type: 'varchar', length: 500 }) + url: string; + + @Column({ type: 'varchar', length: 120 }) + bucket: string; + + @Column({ name: 'mime_type', type: 'varchar', length: 120 }) + mimeType: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + extension?: string | null; + + @Column({ type: 'bigint' }) + size: number; + + @Column({ type: 'varchar', length: 255, nullable: true }) + title?: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + alt?: string | null; + + @Column({ type: 'text', nullable: true }) + caption?: string | null; + + @Column({ type: 'jsonb', default: () => "'{}'" }) + metadata: Record; + + @Column({ name: 'is_public', type: 'boolean', default: true }) + isPublic: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/media/enums/media-section.enum.ts b/src/modules/media/enums/media-section.enum.ts new file mode 100644 index 00000000..9be2944d --- /dev/null +++ b/src/modules/media/enums/media-section.enum.ts @@ -0,0 +1,8 @@ +export enum MediaSection { + IMAGE = 'image', + GALLERY = 'gallery', + AUDIO = 'audio', + VIDEO = 'video', + MODEL_3D = 'model3d', + DOCUMENT = 'document', +} diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts new file mode 100644 index 00000000..4c6edfdc --- /dev/null +++ b/src/modules/media/media.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Permissions } from '../../common/decorators/permissions.decorator'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserRole } from '../users/enums/user-role.enum'; +import { FilterMediaAssetsDto } from './dto/filter-media-assets.dto'; +import { UpdateMediaAssetDto } from './dto/update-media-asset.dto'; +import { UploadMediaDto } from './dto/upload-media.dto'; +import { MediaService } from './media.service'; + +@ApiTags('Admin Media') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard) +@Roles(UserRole.ADMIN) +@Permissions('media.manage') +@Controller('admin/media') +export class MediaController { + constructor(private readonly mediaService: MediaService) {} + + @Get('overview') + @ApiOperation({ summary: 'Get media library section and folder counts' }) + getOverview() { + return this.mediaService.getLibraryOverview(); + } + + @Get() + @ApiOperation({ summary: 'List media assets with filters and pagination' }) + findAll(@Query() filters: FilterMediaAssetsDto) { + return this.mediaService.findAll(filters); + } + + @Post('upload') + @ApiOperation({ summary: 'Upload one or more files into the media library' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: UploadMediaDto }) + @UseInterceptors(FilesInterceptor('files', 30)) + upload( + @UploadedFiles() files: Express.Multer.File[], + @Body() dto: UploadMediaDto, + ) { + return this.mediaService.uploadMany(files, dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one media asset' }) + findOne(@Param('id') id: string) { + return this.mediaService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update media asset metadata' }) + update(@Param('id') id: string, @Body() dto: UpdateMediaAssetDto) { + return this.mediaService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete media asset and underlying object storage file' }) + remove(@Param('id') id: string) { + return this.mediaService.remove(id); + } +} diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts new file mode 100644 index 00000000..944d83b4 --- /dev/null +++ b/src/modules/media/media.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageModule } from '../storage/storage.module'; +import { MediaController } from './media.controller'; +import { MediaAsset } from './entities/media-asset.entity'; +import { MediaService } from './media.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([MediaAsset]), StorageModule], + controllers: [MediaController], + providers: [MediaService], + exports: [TypeOrmModule, MediaService], +}) +export class MediaModule {} diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts new file mode 100644 index 00000000..35fbc49c --- /dev/null +++ b/src/modules/media/media.service.ts @@ -0,0 +1,176 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import * as path from 'path'; +import { Repository } from 'typeorm'; +import { StorageService } from '../storage/storage.service'; +import { FilterMediaAssetsDto } from './dto/filter-media-assets.dto'; +import { UpdateMediaAssetDto } from './dto/update-media-asset.dto'; +import { UploadMediaDto } from './dto/upload-media.dto'; +import { MediaAsset } from './entities/media-asset.entity'; +import { MediaSection } from './enums/media-section.enum'; + +@Injectable() +export class MediaService { + constructor( + @InjectRepository(MediaAsset) + private readonly mediaAssetsRepository: Repository, + private readonly storageService: StorageService, + ) {} + + async uploadMany(files: Express.Multer.File[], dto: UploadMediaDto) { + const uploaded = await Promise.all( + (files ?? []).map(async (file) => { + const section = dto.section ?? this.inferSection(file); + const folder = this.normalizeFolder(dto.folder); + const upload = await this.storageService.uploadPublicFile( + file, + this.buildStorageFolder(section, folder), + ); + + const asset = this.mediaAssetsRepository.create({ + section, + folder, + originalName: file.originalname, + objectName: upload.objectName, + url: upload.url, + bucket: upload.bucket, + mimeType: file.mimetype, + extension: path.extname(file.originalname).replace('.', '') || null, + size: file.size, + title: dto.title ?? null, + alt: dto.alt ?? null, + caption: dto.caption ?? null, + metadata: dto.metadata ?? {}, + isPublic: dto.isPublic ?? true, + }); + + return this.mediaAssetsRepository.save(asset); + }), + ); + + return uploaded; + } + + async findAll(filters: FilterMediaAssetsDto) { + const page = filters.page ?? 1; + const limit = filters.limit ?? 24; + + const query = this.mediaAssetsRepository + .createQueryBuilder('asset') + .orderBy('asset.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + if (filters.section) { + query.andWhere('asset.section = :section', { section: filters.section }); + } + + if (filters.folder) { + query.andWhere('asset.folder = :folder', { folder: filters.folder }); + } + + if (filters.search) { + query.andWhere( + '(asset.original_name ILIKE :search OR asset.title ILIKE :search OR asset.alt ILIKE :search OR asset.caption ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [items, total] = await query.getManyAndCount(); + + return { + items, + meta: { + total, + page, + limit, + }, + }; + } + + async getLibraryOverview() { + const items = await this.mediaAssetsRepository + .createQueryBuilder('asset') + .select('asset.section', 'section') + .addSelect('asset.folder', 'folder') + .addSelect('COUNT(asset.id)', 'count') + .groupBy('asset.section') + .addGroupBy('asset.folder') + .orderBy('asset.section', 'ASC') + .addOrderBy('asset.folder', 'ASC') + .getRawMany<{ section: MediaSection; folder: string; count: string }>(); + + return items.map((item) => ({ + section: item.section, + folder: item.folder, + count: Number(item.count), + })); + } + + async findOne(id: string) { + const asset = await this.mediaAssetsRepository.findOne({ where: { id } }); + if (!asset) { + throw new NotFoundException('Media asset not found'); + } + + return asset; + } + + async update(id: string, dto: UpdateMediaAssetDto) { + const asset = await this.findOne(id); + + Object.assign(asset, { + section: dto.section ?? asset.section, + folder: dto.folder ? this.normalizeFolder(dto.folder) : asset.folder, + title: dto.title ?? asset.title, + alt: dto.alt ?? asset.alt, + caption: dto.caption ?? asset.caption, + metadata: dto.metadata ?? asset.metadata, + isPublic: dto.isPublic ?? asset.isPublic, + }); + + return this.mediaAssetsRepository.save(asset); + } + + async remove(id: string) { + const asset = await this.findOne(id); + await this.storageService.deletePublicFileByUrl(asset.url); + await this.mediaAssetsRepository.remove(asset); + + return { message: 'Media asset deleted successfully' }; + } + + private inferSection(file: Express.Multer.File): MediaSection { + const mimeType = file.mimetype.toLowerCase(); + const extension = path.extname(file.originalname).toLowerCase(); + + if (mimeType.startsWith('image/')) { + return MediaSection.IMAGE; + } + + if (mimeType.startsWith('audio/')) { + return MediaSection.AUDIO; + } + + if (mimeType.startsWith('video/')) { + return MediaSection.VIDEO; + } + + if (['.glb', '.gltf', '.obj', '.fbx', '.stl', '.usdz'].includes(extension)) { + return MediaSection.MODEL_3D; + } + + return MediaSection.DOCUMENT; + } + + private buildStorageFolder(section: MediaSection, folder: string) { + return `media/${section}/${folder}`; + } + + private normalizeFolder(folder?: string) { + return folder?.trim().replace(/^\/+|\/+$/g, '') || 'root'; + } +} diff --git a/src/modules/storage/storage.module.ts b/src/modules/storage/storage.module.ts new file mode 100644 index 00000000..8151be1f --- /dev/null +++ b/src/modules/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/src/modules/storage/storage.service.ts b/src/modules/storage/storage.service.ts new file mode 100644 index 00000000..05c2ef06 --- /dev/null +++ b/src/modules/storage/storage.service.ts @@ -0,0 +1,160 @@ +import { + Injectable, + InternalServerErrorException, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import * as Minio from 'minio'; + +export interface StoredFileResult { + bucket: string; + objectName: string; + url: string; +} + +@Injectable() +export class StorageService implements OnModuleInit { + private readonly logger = new Logger(StorageService.name); + private readonly client: Minio.Client; + private readonly publicBucket: string; + private readonly privateBucket: string; + private readonly publicUrl?: string; + + constructor(private readonly configService: ConfigService) { + this.client = new Minio.Client({ + endPoint: this.configService.getOrThrow('minio.endpoint'), + port: this.configService.get('minio.port', 9000), + useSSL: this.configService.get('minio.useSsl', false), + accessKey: this.configService.getOrThrow('minio.accessKey'), + secretKey: this.configService.getOrThrow('minio.secretKey'), + }); + this.publicBucket = this.configService.getOrThrow('minio.publicBucket'); + this.privateBucket = this.configService.getOrThrow('minio.privateBucket'); + this.publicUrl = this.configService.get('minio.publicUrl'); + } + + async onModuleInit() { + await this.ensureBucket(this.publicBucket, true); + await this.ensureBucket(this.privateBucket, false); + } + + async uploadPublicFile( + file: Express.Multer.File, + folder = 'products', + ): Promise { + return this.upload(file, this.publicBucket, folder); + } + + async uploadPrivateFile( + file: Express.Multer.File, + folder = 'products', + ): Promise { + return this.upload(file, this.privateBucket, folder); + } + + async deleteFile(bucket: string, objectName: string): Promise { + try { + await this.client.removeObject(bucket, objectName); + } catch (error) { + this.logger.warn( + `Failed to delete object ${bucket}/${objectName}: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + } + } + + async deletePublicFileByUrl(fileUrl?: string | null): Promise { + if (!fileUrl) { + return; + } + + const objectName = this.extractObjectName(fileUrl, this.publicBucket); + if (objectName) { + await this.deleteFile(this.publicBucket, objectName); + } + } + + extractObjectName(fileUrl: string, bucket: string): string | null { + if (!fileUrl) { + return null; + } + + try { + const parsedUrl = new URL(fileUrl); + const prefix = `/${bucket}/`; + const path = parsedUrl.pathname.startsWith(prefix) + ? parsedUrl.pathname.slice(prefix.length) + : parsedUrl.pathname.replace(/^\//, ''); + return decodeURIComponent(path); + } catch { + return null; + } + } + + private async upload( + file: Express.Multer.File, + bucket: string, + folder: string, + ): Promise { + if (!file) { + throw new InternalServerErrorException('File upload payload is empty'); + } + + const objectName = `${folder}/${randomUUID()}-${file.originalname.replace(/\s+/g, '-')}`; + + try { + await this.client.putObject(bucket, objectName, file.buffer, file.size, { + 'Content-Type': file.mimetype, + }); + } catch (error) { + throw new InternalServerErrorException( + `File upload failed: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + } + + return { + bucket, + objectName, + url: this.buildPublicUrl(bucket, objectName), + }; + } + + private async ensureBucket(bucketName: string, makePublic: boolean) { + const exists = await this.client.bucketExists(bucketName); + if (!exists) { + await this.client.makeBucket(bucketName); + } + + if (makePublic) { + await this.client.setBucketPolicy( + bucketName, + JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${bucketName}/*`], + }, + ], + }), + ); + } + } + + private buildPublicUrl(bucket: string, objectName: string) { + if (this.publicUrl) { + return `${this.publicUrl.replace(/\/$/, '')}/${bucket}/${objectName}`; + } + + const protocol = this.configService.get('minio.useSsl', false) + ? 'https' + : 'http'; + const endpoint = this.configService.getOrThrow('minio.endpoint'); + const port = this.configService.get('minio.port', 9000); + + return `${protocol}://${endpoint}:${port}/${bucket}/${objectName}`; + } +} diff --git a/src/modules/users/entities/loyalty-profile.entity.ts b/src/modules/users/entities/loyalty-profile.entity.ts new file mode 100644 index 00000000..ddc97785 --- /dev/null +++ b/src/modules/users/entities/loyalty-profile.entity.ts @@ -0,0 +1,53 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserLevel } from '../enums/user-level.enum'; +import { User } from './user.entity'; +import { UserLevelHistory } from './user-level-history.entity'; + +@Entity({ name: 'loyalty_profiles' }) +export class LoyaltyProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @OneToOne(() => User, (user) => user.loyaltyProfile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ + name: 'current_level', + type: 'enum', + enum: UserLevel, + default: UserLevel.BRONZE, + }) + currentLevel: UserLevel; + + @Column({ + name: 'total_spent', + type: 'numeric', + precision: 12, + scale: 2, + default: 0, + transformer: { + to: (value: number) => value, + from: (value: string) => Number(value), + }, + }) + totalSpent: number; + + @OneToMany(() => UserLevelHistory, (history) => history.loyaltyProfile) + history: UserLevelHistory[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/users/entities/user-level-history.entity.ts b/src/modules/users/entities/user-level-history.entity.ts new file mode 100644 index 00000000..c3b58f5c --- /dev/null +++ b/src/modules/users/entities/user-level-history.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { UserLevel } from '../enums/user-level.enum'; +import { LoyaltyProfile } from './loyalty-profile.entity'; + +@Entity({ name: 'user_level_histories' }) +export class UserLevelHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => LoyaltyProfile, (profile) => profile.history, { + onDelete: 'CASCADE', + }) + loyaltyProfile: LoyaltyProfile; + + @Column({ + name: 'level', + type: 'enum', + enum: UserLevel, + }) + level: UserLevel; + + @Column({ name: 'reason', type: 'varchar', length: 255, nullable: true }) + reason?: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 4de8dc3b..f5b555e3 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -2,11 +2,15 @@ import { Column, CreateDateColumn, Entity, + OneToMany, + OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { UserLevel } from '../enums/user-level.enum'; +import { UserSession } from '../../auth/entities/user-session.entity'; import { UserRole } from '../enums/user-role.enum'; +import { LoyaltyProfile } from './loyalty-profile.entity'; +import { Wallet } from './wallet.entity'; @Entity({ name: 'users' }) export class User { @@ -16,6 +20,9 @@ export class User { @Column({ unique: true, length: 20 }) phone: string; + @Column({ type: 'varchar', unique: true, nullable: true, length: 50 }) + username?: string | null; + @Column({ name: 'full_name', length: 150 }) fullName: string; @@ -26,37 +33,22 @@ export class User { }) role: UserRole; - @Column({ - type: 'enum', - enum: UserLevel, - default: UserLevel.BRONZE, - }) - level: UserLevel; - @Column({ name: 'is_verified', default: false }) isVerified: boolean; - @Column({ - name: 'wallet_balance', - type: 'numeric', - precision: 12, - scale: 2, - default: 0, - transformer: { - to: (value: number) => value, - from: (value: string) => Number(value), - }, + @Column({ type: 'varchar', name: 'password_hash', nullable: true, length: 255 }) + passwordHash?: string | null; + + @OneToOne(() => Wallet, (wallet) => wallet.user, { eager: true }) + wallet: Wallet; + + @OneToOne(() => LoyaltyProfile, (loyaltyProfile) => loyaltyProfile.user, { + eager: true, }) - walletBalance: number; + loyaltyProfile: LoyaltyProfile; - @Column({ name: 'otp_code', nullable: true, length: 10 }) - otpCode?: string | null; - - @Column({ name: 'otp_expires_at', nullable: true, type: 'timestamp with time zone' }) - otpExpiresAt?: Date | null; - - @Column({ name: 'refresh_token_hash', nullable: true, length: 255 }) - refreshTokenHash?: string | null; + @OneToMany(() => UserSession, (session) => session.user) + sessions: UserSession[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/src/modules/users/entities/wallet-transaction.entity.ts b/src/modules/users/entities/wallet-transaction.entity.ts new file mode 100644 index 00000000..a578b176 --- /dev/null +++ b/src/modules/users/entities/wallet-transaction.entity.ts @@ -0,0 +1,39 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Wallet } from './wallet.entity'; + +@Entity({ name: 'wallet_transactions' }) +export class WalletTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Wallet, (wallet) => wallet.transactions, { + onDelete: 'CASCADE', + }) + wallet: Wallet; + + @Column({ length: 30 }) + type: string; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: { + to: (value: number) => value, + from: (value: string) => Number(value), + }, + }) + amount: number; + + @Column({ type: 'varchar', length: 255, nullable: true }) + description?: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/src/modules/users/entities/wallet.entity.ts b/src/modules/users/entities/wallet.entity.ts new file mode 100644 index 00000000..039641e0 --- /dev/null +++ b/src/modules/users/entities/wallet.entity.ts @@ -0,0 +1,44 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { WalletTransaction } from './wallet-transaction.entity'; +import { User } from './user.entity'; + +@Entity({ name: 'wallets' }) +export class Wallet { + @PrimaryGeneratedColumn('uuid') + id: string; + + @OneToOne(() => User, (user) => user.wallet, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ + name: 'balance', + type: 'numeric', + precision: 12, + scale: 2, + default: 0, + transformer: { + to: (value: number) => value, + from: (value: string) => Number(value), + }, + }) + balance: number; + + @OneToMany(() => WalletTransaction, (transaction) => transaction.wallet) + transactions: WalletTransaction[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index f8cc9306..54e78bbe 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,10 +1,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { LoyaltyProfile } from './entities/loyalty-profile.entity'; import { User } from './entities/user.entity'; +import { UserLevelHistory } from './entities/user-level-history.entity'; +import { WalletTransaction } from './entities/wallet-transaction.entity'; +import { Wallet } from './entities/wallet.entity'; import { UsersService } from './users.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [ + TypeOrmModule.forFeature([ + User, + Wallet, + WalletTransaction, + LoyaltyProfile, + UserLevelHistory, + ]), + ], providers: [UsersService], exports: [UsersService], }) diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 0eea08b0..2e70a7c2 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -1,39 +1,93 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { LoyaltyProfile } from './entities/loyalty-profile.entity'; import { User } from './entities/user.entity'; +import { UserLevelHistory } from './entities/user-level-history.entity'; +import { Wallet } from './entities/wallet.entity'; import { UserRole } from './enums/user-role.enum'; +import { UserLevel } from './enums/user-level.enum'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private readonly usersRepository: Repository, + @InjectRepository(Wallet) + private readonly walletsRepository: Repository, + @InjectRepository(LoyaltyProfile) + private readonly loyaltyProfilesRepository: Repository, + @InjectRepository(UserLevelHistory) + private readonly userLevelHistoriesRepository: Repository, ) {} findByPhone(phone: string) { - return this.usersRepository.findOne({ where: { phone } }); + return this.usersRepository.findOne({ + where: { phone }, + relations: { wallet: true, loyaltyProfile: true }, + }); + } + + findByUsername(username: string) { + return this.usersRepository.findOne({ + where: { username }, + relations: { wallet: true, loyaltyProfile: true }, + }); } findById(id: string) { - return this.usersRepository.findOne({ where: { id } }); + return this.usersRepository.findOne({ + where: { id }, + relations: { wallet: true, loyaltyProfile: true }, + }); } async findOrCreateByPhone(phone: string, fullName?: string) { let user = await this.findByPhone(phone); if (!user) { - user = this.usersRepository.create({ + user = await this.create({ phone, fullName: fullName ?? phone, role: UserRole.USER, }); - user = await this.usersRepository.save(user); } return user; } + async create(payload: Partial) { + const user = this.usersRepository.create(payload); + const savedUser = await this.usersRepository.save(user); + + const wallet = this.walletsRepository.create({ + user: savedUser, + balance: 0, + }); + await this.walletsRepository.save(wallet); + + const loyaltyProfile = this.loyaltyProfilesRepository.create({ + user: savedUser, + currentLevel: UserLevel.BRONZE, + totalSpent: 0, + }); + await this.loyaltyProfilesRepository.save(loyaltyProfile); + + const levelHistory = this.userLevelHistoriesRepository.create({ + loyaltyProfile, + level: loyaltyProfile.currentLevel, + reason: 'Initial level assignment', + }); + await this.userLevelHistoriesRepository.save(levelHistory); + + const createdUser = await this.findById(savedUser.id); + if (!createdUser) { + throw new Error('User creation failed'); + } + + return createdUser; + } + async save(user: User) { return this.usersRepository.save(user); } diff --git a/tmp-start.err b/tmp-start.err new file mode 100644 index 00000000..f1c0e235 --- /dev/null +++ b/tmp-start.err @@ -0,0 +1,35 @@ +[Nest] 24228 - 03/24/2026, 1:50:10 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:50:34 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (2)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:50:58 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (3)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:51:22 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (4)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:51:46 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (5)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:52:10 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (6)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:52:34 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (7)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:52:58 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (8)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:53:22 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (9)... +Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) +[Nest] 24228 - 03/24/2026, 1:53:22 PM  ERROR [ExceptionHandler] Error: connect ETIMEDOUT 62.3.14.124:6986 + at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) { + errno: -4039, + code: 'ETIMEDOUT', + syscall: 'connect', + address: '62.3.14.124', + port: 6986 +} diff --git a/tmp-start.out b/tmp-start.out new file mode 100644 index 00000000..b94b845d --- /dev/null +++ b/tmp-start.out @@ -0,0 +1,13 @@ + +> parsshop-back@0.1.0 start +> nest start + +[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [NestFactory] Starting Nest application... +[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +19ms +[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] PassportModule dependencies initialized +2ms +[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +3ms +[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] AppModule dependencies initialized +4ms +[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +1ms +[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms +[Nest] 24228 - 03/24/2026, 1:49:49 PM  LOG [InstanceLoader] StorageModule dependencies initialized +45ms +[Nest] 24228 - 03/24/2026, 1:49:49 PM  LOG [InstanceLoader] JwtModule dependencies initialized +1ms