update product
This commit is contained in:
23
.env.example
23
.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
|
||||
|
||||
16
README.md
16
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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
135
docs/brands-api.md
Normal file
135
docs/brands-api.md
Normal file
@@ -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`
|
||||
150
docs/media-library-api.md
Normal file
150
docs/media-library-api.md
Normal file
@@ -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/<section>/<folder>/...`
|
||||
|
||||
### `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.
|
||||
255
docs/products-api.md
Normal file
255
docs/products-api.md
Normal file
@@ -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=<uuid>
|
||||
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
|
||||
668
package-lock.json
generated
668
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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],
|
||||
|
||||
21
src/common/utils/json-transform.util.ts
Normal file
21
src/common/utils/json-transform.util.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
|
||||
@@ -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<string>('database.url'),
|
||||
entities: [User, Product, Category],
|
||||
autoLoadEntities: false,
|
||||
synchronize: true,
|
||||
});
|
||||
): TypeOrmModuleOptions => {
|
||||
const sslEnabled = configService.get<boolean>('database.ssl', false);
|
||||
|
||||
return {
|
||||
type: 'postgres',
|
||||
url: configService.get<string>('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],
|
||||
|
||||
10
src/main.ts
10
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<AuthOtp>,
|
||||
@InjectRepository(UserSession)
|
||||
private readonly userSessionsRepository: Repository<UserSession>,
|
||||
) {}
|
||||
|
||||
async requestOtp(phone: string, fullName?: string) {
|
||||
const user = await this.usersService.findOrCreateByPhone(phone, fullName);
|
||||
const otpCode = this.generateOtp();
|
||||
const ttlSeconds = this.configService.get<number>('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<string>('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<StringValue>('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<UserSession | null> {
|
||||
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<string, number> = {
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
return amount * unitMap[unit];
|
||||
}
|
||||
}
|
||||
|
||||
13
src/modules/auth/dto/login-password.dto.ts
Normal file
13
src/modules/auth/dto/login-password.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
22
src/modules/auth/dto/register-password.dto.ts
Normal file
22
src/modules/auth/dto/register-password.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
39
src/modules/auth/entities/auth-otp.entity.ts
Normal file
39
src/modules/auth/entities/auth-otp.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
41
src/modules/auth/entities/user-session.entity.ts
Normal file
41
src/modules/auth/entities/user-session.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
50
src/modules/auth/sms.service.ts
Normal file
50
src/modules/auth/sms.service.ts
Normal file
@@ -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<boolean> {
|
||||
const wsdlUrl = this.configService.get<string>('sms.wsdlUrl');
|
||||
const username = this.configService.get<string>('sms.username');
|
||||
const password = this.configService.get<string>('sms.password');
|
||||
const fromNumber = this.configService.get<string>('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');
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/modules/catalog/admin-products.controller.ts
Normal file
136
src/modules/catalog/admin-products.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
54
src/modules/catalog/attribute-definitions.controller.ts
Normal file
54
src/modules/catalog/attribute-definitions.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
87
src/modules/catalog/brand.controller.ts
Normal file
87
src/modules/catalog/brand.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
86
src/modules/catalog/brand.service.ts
Normal file
86
src/modules/catalog/brand.service.ts
Normal file
@@ -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<Brand>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
87
src/modules/catalog/category.controller.ts
Normal file
87
src/modules/catalog/category.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
126
src/modules/catalog/category.service.ts
Normal file
126
src/modules/catalog/category.service.ts
Normal file
@@ -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<Category>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/modules/catalog/dto/check-product-slug.dto.ts
Normal file
14
src/modules/catalog/dto/check-product-slug.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
57
src/modules/catalog/dto/create-attribute-definition.dto.ts
Normal file
57
src/modules/catalog/dto/create-attribute-definition.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
34
src/modules/catalog/dto/create-brand.dto.ts
Normal file
34
src/modules/catalog/dto/create-brand.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
32
src/modules/catalog/dto/create-category.dto.ts
Normal file
32
src/modules/catalog/dto/create-category.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
39
src/modules/catalog/dto/create-product-review.dto.ts
Normal file
39
src/modules/catalog/dto/create-product-review.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
161
src/modules/catalog/dto/create-product.dto.ts
Normal file
161
src/modules/catalog/dto/create-product.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
40
src/modules/catalog/dto/filter-product-reviews.dto.ts
Normal file
40
src/modules/catalog/dto/filter-product-reviews.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
71
src/modules/catalog/dto/filter-products.dto.ts
Normal file
71
src/modules/catalog/dto/filter-products.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
@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;
|
||||
}
|
||||
21
src/modules/catalog/dto/moderate-product-review.dto.ts
Normal file
21
src/modules/catalog/dto/moderate-product-review.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
123
src/modules/catalog/dto/product-attribute-input.dto.ts
Normal file
123
src/modules/catalog/dto/product-attribute-input.dto.ts
Normal file
@@ -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, unknown> | 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, unknown> | string[];
|
||||
|
||||
@ApiPropertyOptional({ example: 'g' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
overrideUnit?: string;
|
||||
}
|
||||
45
src/modules/catalog/dto/product-meta.dto.ts
Normal file
45
src/modules/catalog/dto/product-meta.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateAttributeDefinitionDto } from './create-attribute-definition.dto';
|
||||
|
||||
export class UpdateAttributeDefinitionDto extends PartialType(
|
||||
CreateAttributeDefinitionDto,
|
||||
) {}
|
||||
4
src/modules/catalog/dto/update-brand.dto.ts
Normal file
4
src/modules/catalog/dto/update-brand.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateBrandDto } from './create-brand.dto';
|
||||
|
||||
export class UpdateBrandDto extends PartialType(CreateBrandDto) {}
|
||||
4
src/modules/catalog/dto/update-category.dto.ts
Normal file
4
src/modules/catalog/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCategoryDto } from './create-category.dto';
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
4
src/modules/catalog/dto/update-product.dto.ts
Normal file
4
src/modules/catalog/dto/update-product.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateProductDto } from './create-product.dto';
|
||||
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto) {}
|
||||
64
src/modules/catalog/entities/attribute-definition.entity.ts
Normal file
64
src/modules/catalog/entities/attribute-definition.entity.ts
Normal file
@@ -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, unknown> | 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;
|
||||
}
|
||||
40
src/modules/catalog/entities/brand.entity.ts
Normal file
40
src/modules/catalog/entities/brand.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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, unknown> | string[] | null;
|
||||
|
||||
@Column({ name: 'override_unit', type: 'varchar', length: 50, nullable: true })
|
||||
overrideUnit?: string | null;
|
||||
}
|
||||
41
src/modules/catalog/entities/product-meta.entity.ts
Normal file
41
src/modules/catalog/entities/product-meta.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
49
src/modules/catalog/entities/product-review.entity.ts
Normal file
49
src/modules/catalog/entities/product-review.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
@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;
|
||||
|
||||
8
src/modules/catalog/enums/attribute-data-type.enum.ts
Normal file
8
src/modules/catalog/enums/attribute-data-type.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum AttributeDataType {
|
||||
TEXT = 'text',
|
||||
NUMBER = 'number',
|
||||
BOOLEAN = 'boolean',
|
||||
SELECT = 'select',
|
||||
MULTISELECT = 'multiselect',
|
||||
JSON = 'json',
|
||||
}
|
||||
5
src/modules/catalog/enums/product-status.enum.ts
Normal file
5
src/modules/catalog/enums/product-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum ProductStatus {
|
||||
DRAFT = 'draft',
|
||||
PUBLISHED = 'published',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
42
src/modules/catalog/products.controller.ts
Normal file
42
src/modules/catalog/products.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
899
src/modules/catalog/products.service.ts
Normal file
899
src/modules/catalog/products.service.ts
Normal file
@@ -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<Product>,
|
||||
@InjectRepository(ProductMeta)
|
||||
private readonly productMetaRepository: Repository<ProductMeta>,
|
||||
@InjectRepository(ProductAttributeValue)
|
||||
private readonly productAttributeValuesRepository: Repository<ProductAttributeValue>,
|
||||
@InjectRepository(AttributeDefinition)
|
||||
private readonly attributeDefinitionsRepository: Repository<AttributeDefinition>,
|
||||
@InjectRepository(ProductReview)
|
||||
private readonly productReviewsRepository: Repository<ProductReview>,
|
||||
@InjectRepository(Category)
|
||||
private readonly categoriesRepository: Repository<Category>,
|
||||
@InjectRepository(Brand)
|
||||
private readonly brandsRepository: Repository<Brand>,
|
||||
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<Product>,
|
||||
filters?: Record<string, unknown>,
|
||||
) {
|
||||
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<void> {
|
||||
if (currentUrl && currentUrl !== nextUrl) {
|
||||
await this.storageService.deletePublicFileByUrl(currentUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/modules/media/dto/filter-media-assets.dto.ts
Normal file
35
src/modules/media/dto/filter-media-assets.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
src/modules/media/dto/update-media-asset.dto.ts
Normal file
4
src/modules/media/dto/update-media-asset.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { UploadMediaDto } from './upload-media.dto';
|
||||
|
||||
export class UpdateMediaAssetDto extends PartialType(UploadMediaDto) {}
|
||||
56
src/modules/media/dto/upload-media.dto.ts
Normal file
56
src/modules/media/dto/upload-media.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
68
src/modules/media/entities/media-asset.entity.ts
Normal file
68
src/modules/media/entities/media-asset.entity.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
@Column({ name: 'is_public', type: 'boolean', default: true })
|
||||
isPublic: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
8
src/modules/media/enums/media-section.enum.ts
Normal file
8
src/modules/media/enums/media-section.enum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum MediaSection {
|
||||
IMAGE = 'image',
|
||||
GALLERY = 'gallery',
|
||||
AUDIO = 'audio',
|
||||
VIDEO = 'video',
|
||||
MODEL_3D = 'model3d',
|
||||
DOCUMENT = 'document',
|
||||
}
|
||||
83
src/modules/media/media.controller.ts
Normal file
83
src/modules/media/media.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
14
src/modules/media/media.module.ts
Normal file
14
src/modules/media/media.module.ts
Normal file
@@ -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 {}
|
||||
176
src/modules/media/media.service.ts
Normal file
176
src/modules/media/media.service.ts
Normal file
@@ -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<MediaAsset>,
|
||||
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';
|
||||
}
|
||||
}
|
||||
9
src/modules/storage/storage.module.ts
Normal file
9
src/modules/storage/storage.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
160
src/modules/storage/storage.service.ts
Normal file
160
src/modules/storage/storage.service.ts
Normal file
@@ -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<string>('minio.endpoint'),
|
||||
port: this.configService.get<number>('minio.port', 9000),
|
||||
useSSL: this.configService.get<boolean>('minio.useSsl', false),
|
||||
accessKey: this.configService.getOrThrow<string>('minio.accessKey'),
|
||||
secretKey: this.configService.getOrThrow<string>('minio.secretKey'),
|
||||
});
|
||||
this.publicBucket = this.configService.getOrThrow<string>('minio.publicBucket');
|
||||
this.privateBucket = this.configService.getOrThrow<string>('minio.privateBucket');
|
||||
this.publicUrl = this.configService.get<string>('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<StoredFileResult> {
|
||||
return this.upload(file, this.publicBucket, folder);
|
||||
}
|
||||
|
||||
async uploadPrivateFile(
|
||||
file: Express.Multer.File,
|
||||
folder = 'products',
|
||||
): Promise<StoredFileResult> {
|
||||
return this.upload(file, this.privateBucket, folder);
|
||||
}
|
||||
|
||||
async deleteFile(bucket: string, objectName: string): Promise<void> {
|
||||
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<void> {
|
||||
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<StoredFileResult> {
|
||||
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<boolean>('minio.useSsl', false)
|
||||
? 'https'
|
||||
: 'http';
|
||||
const endpoint = this.configService.getOrThrow<string>('minio.endpoint');
|
||||
const port = this.configService.get<number>('minio.port', 9000);
|
||||
|
||||
return `${protocol}://${endpoint}:${port}/${bucket}/${objectName}`;
|
||||
}
|
||||
}
|
||||
53
src/modules/users/entities/loyalty-profile.entity.ts
Normal file
53
src/modules/users/entities/loyalty-profile.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
33
src/modules/users/entities/user-level-history.entity.ts
Normal file
33
src/modules/users/entities/user-level-history.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
39
src/modules/users/entities/wallet-transaction.entity.ts
Normal file
39
src/modules/users/entities/wallet-transaction.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
44
src/modules/users/entities/wallet.entity.ts
Normal file
44
src/modules/users/entities/wallet.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<User>,
|
||||
@InjectRepository(Wallet)
|
||||
private readonly walletsRepository: Repository<Wallet>,
|
||||
@InjectRepository(LoyaltyProfile)
|
||||
private readonly loyaltyProfilesRepository: Repository<LoyaltyProfile>,
|
||||
@InjectRepository(UserLevelHistory)
|
||||
private readonly userLevelHistoriesRepository: Repository<UserLevelHistory>,
|
||||
) {}
|
||||
|
||||
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<User>) {
|
||||
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);
|
||||
}
|
||||
|
||||
35
tmp-start.err
Normal file
35
tmp-start.err
Normal file
@@ -0,0 +1,35 @@
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:50:10 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (1)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:50:34 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (2)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:50:58 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (3)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:51:22 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (4)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:51:46 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (5)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:52:10 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (6)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:52:34 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (7)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:52:58 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (8)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:53:22 PM [31m ERROR[39m [38;5;3m[TypeOrmModule] [39m[31mUnable to connect to the database. Retrying (9)...[39m
|
||||
Error: connect ETIMEDOUT 62.3.14.124:6986
|
||||
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
|
||||
[31m[Nest] 24228 - [39m03/24/2026, 1:53:22 PM [31m ERROR[39m [38;5;3m[ExceptionHandler] [39mError: connect ETIMEDOUT 62.3.14.124:6986
|
||||
[90m at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)[39m {
|
||||
errno: [33m-4039[39m,
|
||||
code: [32m'ETIMEDOUT'[39m,
|
||||
syscall: [32m'connect'[39m,
|
||||
address: [32m'62.3.14.124'[39m,
|
||||
port: [33m6986[39m
|
||||
}
|
||||
13
tmp-start.out
Normal file
13
tmp-start.out
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
> parsshop-back@0.1.0 start
|
||||
> nest start
|
||||
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:48 PM [32m LOG[39m [38;5;3m[NestFactory] [39m[32mStarting Nest application...[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:48 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mTypeOrmModule dependencies initialized[39m[38;5;3m +19ms[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:48 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mPassportModule dependencies initialized[39m[38;5;3m +2ms[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:48 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigHostModule dependencies initialized[39m[38;5;3m +3ms[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:48 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mAppModule dependencies initialized[39m[38;5;3m +4ms[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:48 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigModule dependencies initialized[39m[38;5;3m +1ms[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:48 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mConfigModule dependencies initialized[39m[38;5;3m +0ms[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:49 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mStorageModule dependencies initialized[39m[38;5;3m +45ms[39m
|
||||
[32m[Nest] 24228 - [39m03/24/2026, 1:49:49 PM [32m LOG[39m [38;5;3m[InstanceLoader] [39m[32mJwtModule dependencies initialized[39m[38;5;3m +1ms[39m
|
||||
Reference in New Issue
Block a user