update product
This commit is contained in:
23
.env.example
23
.env.example
@@ -1,14 +1,23 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
DB_URL=postgres://parsdbshop:ZtKKAQWA00umtkNXUMcjVNRD6avXFOVDOfqGcTTLwhnGUYq6EnSvaYsyJi06sx6j@62.3.14.124:6986/postgres
|
DB_URL=postgres://postgres:postgres@localhost:5432/parsshop
|
||||||
REDIS_URL=redis://parsuserdb:xTpObuam6vTAAtWhn92rvQdo8rjhO22K4IxyJxdooUAPoyY9zLbYSYBSRm6io7E6@62.3.14.124:6801/0
|
DB_SSL=false
|
||||||
MINIO_ENDPOINT=s3.ir-thr-at1.arvanstorage.ir
|
REDIS_URL=redis://localhost:6379
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
MINIO_ACCESS_KEY=8e66af66-67cb-4dcb-ba62-36e88ad7083e
|
MINIO_USE_SSL=false
|
||||||
MINIO_SECRET_KEY=770b6bd2f4a93313312dd29bdee80fd57b1490ec86039124b44333a8f150d138
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
MINIO_BUCKET=pod
|
MINIO_SECRET_KEY=minioadmin
|
||||||
JWT_SECRET=HJAKINMAqi1732bJHGHABADRMESTAhad
|
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_ACCESS_TTL=15m
|
||||||
JWT_REFRESH_TTL=30d
|
JWT_REFRESH_TTL=30d
|
||||||
SMS_API_KEY=replace-me
|
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
|
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
|
## Included
|
||||||
|
|
||||||
- PostgreSQL + TypeORM
|
- PostgreSQL + TypeORM
|
||||||
- Docker Compose for PostgreSQL, Redis, and MinIO
|
|
||||||
- Global validation pipe
|
- Global validation pipe
|
||||||
- Standard API response interceptor
|
- Standard API response interceptor
|
||||||
- Core entities: User, Product, Category
|
- Core entities: User, Product, Category
|
||||||
@@ -15,12 +14,7 @@ Phase 1 bootstrap for a NestJS backend focused on bearings e-commerce.
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env`
|
1. Copy `.env.example` to `.env`
|
||||||
2. Start infrastructure:
|
2. Make sure your PostgreSQL service is running and matches `DB_URL`
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Install dependencies:
|
3. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -30,5 +24,11 @@ npm install
|
|||||||
4. Run the app:
|
4. Run the app:
|
||||||
|
|
||||||
```bash
|
```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/jwt": "^11.0.0",
|
||||||
"@nestjs/passport": "^11.0.0",
|
"@nestjs/passport": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"iterare": "1.2.1",
|
||||||
|
"minio": "^8.0.5",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"redis": "^5.1.0",
|
"redis": "^5.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"soap": "^1.1.11",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.20"
|
"typeorm": "^0.3.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -33,6 +38,7 @@
|
|||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
@@ -990,6 +996,12 @@
|
|||||||
"node-pre-gyp": "bin/node-pre-gyp"
|
"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": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "11.0.16",
|
"version": "11.0.16",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@nestjs/cli/-/cli-11.0.16.tgz",
|
"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"
|
"@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": {
|
"node_modules/@nestjs/passport": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@nestjs/passport/-/passport-11.0.5.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||||
@@ -1240,6 +1272,39 @@
|
|||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@nestjs/testing": {
|
||||||
"version": "11.1.17",
|
"version": "11.1.17",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@nestjs/testing/-/testing-11.1.17.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@nestjs/testing/-/testing-11.1.17.tgz",
|
||||||
@@ -1281,6 +1346,18 @@
|
|||||||
"typeorm": "^0.3.0"
|
"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": {
|
"node_modules/@nuxt/opencollective": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@nuxt/opencollective/-/opencollective-0.4.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@nuxt/opencollective/-/opencollective-0.4.1.tgz",
|
||||||
@@ -1297,6 +1374,15 @@
|
|||||||
"npm": ">=5.10.0"
|
"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": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -1388,6 +1474,13 @@
|
|||||||
"@redis/client": "^5.11.0"
|
"@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": {
|
"node_modules/@sqltools/formatter": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
||||||
@@ -1560,6 +1653,16 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@types/node/-/node-22.19.15.tgz",
|
||||||
@@ -1803,6 +1906,24 @@
|
|||||||
"@xtuc/long": "4.2.2"
|
"@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": {
|
"node_modules/@xtuc/ieee754": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||||
@@ -2034,7 +2155,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/array-timsort": {
|
"node_modules/array-timsort": {
|
||||||
@@ -2044,6 +2164,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://mirror-npm.runflare.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"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"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -2124,6 +2285,15 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
@@ -2158,6 +2328,12 @@
|
|||||||
"concat-map": "0.0.1"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -2217,6 +2393,15 @@
|
|||||||
"ieee754": "^1.1.13"
|
"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": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001780",
|
"version": "1.0.30001781",
|
||||||
"resolved": "https://mirror-npm.runflare.com/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
|
"resolved": "https://mirror-npm.runflare.com/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
|
||||||
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
|
"integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2532,6 +2717,18 @@
|
|||||||
"color-support": "bin.js"
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/commander/-/commander-4.1.1.tgz",
|
"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": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -2789,6 +2995,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/delegates": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/delegates/-/delegates-1.0.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/delegates/-/delegates-1.0.0.tgz",
|
||||||
@@ -2804,6 +3019,16 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -2813,6 +3038,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://mirror-npm.runflare.com/diff/-/diff-4.0.4.tgz",
|
"resolved": "https://mirror-npm.runflare.com/diff/-/diff-4.0.4.tgz",
|
||||||
@@ -2980,6 +3221,21 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -3256,6 +3512,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/events/-/events-3.3.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/events/-/events-3.3.0.tgz",
|
||||||
@@ -3360,6 +3622,41 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/finalhandler": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
@@ -3450,6 +3756,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://mirror-npm.runflare.com/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://mirror-npm.runflare.com/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -3509,6 +3835,60 @@
|
|||||||
"webpack": "^5.11.0"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -3961,12 +4341,12 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "2.3.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
@@ -4131,6 +4511,12 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4142,7 +4528,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -4549,6 +4934,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://mirror-npm.runflare.com/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://mirror-npm.runflare.com/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -4571,6 +4962,51 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://mirror-npm.runflare.com/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://mirror-npm.runflare.com/minipass/-/minipass-7.1.3.tgz",
|
||||||
@@ -5021,6 +5457,21 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -5300,6 +5751,21 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5325,6 +5791,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://mirror-npm.runflare.com/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://mirror-npm.runflare.com/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -5537,6 +6021,15 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/schema-utils": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/schema-utils/-/schema-utils-3.3.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||||
@@ -5801,6 +6294,25 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://mirror-npm.runflare.com/source-map/-/source-map-0.7.4.tgz",
|
"resolved": "https://mirror-npm.runflare.com/source-map/-/source-map-0.7.4.tgz",
|
||||||
@@ -5832,6 +6344,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/split2": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/split2/-/split2-4.2.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/split2/-/split2-4.2.0.tgz",
|
||||||
@@ -5866,6 +6387,21 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/streamsearch": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/streamsearch/-/streamsearch-1.1.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
@@ -5874,6 +6410,15 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -5960,6 +6505,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/strtok3": {
|
||||||
"version": "10.3.5",
|
"version": "10.3.5",
|
||||||
"resolved": "https://mirror-npm.runflare.com/strtok3/-/strtok3-10.3.5.tgz",
|
"resolved": "https://mirror-npm.runflare.com/strtok3/-/strtok3-10.3.5.tgz",
|
||||||
@@ -5989,6 +6546,30 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/symbol-observable": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
||||||
@@ -6016,9 +6597,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://mirror-npm.runflare.com/tapable/-/tapable-2.3.2.tgz",
|
||||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6165,6 +6746,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/to-buffer": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/to-buffer/-/to-buffer-1.2.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/to-buffer/-/to-buffer-1.2.2.tgz",
|
||||||
@@ -6886,6 +7476,15 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://mirror-npm.runflare.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
"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==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://mirror-npm.runflare.com/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://mirror-npm.runflare.com/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -21,16 +21,21 @@
|
|||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/passport": "^11.0.0",
|
"@nestjs/passport": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"iterare": "1.2.1",
|
||||||
|
"minio": "^8.0.5",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"redis": "^5.1.0",
|
"redis": "^5.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"soap": "^1.1.11",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.20"
|
"typeorm": "^0.3.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -39,6 +44,7 @@
|
|||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.10.1",
|
"@types/node": "^22.10.1",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|||||||
@@ -7,10 +7,24 @@ import configuration from './config/configuration';
|
|||||||
import { validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
import { typeOrmConfigFactory } from './config/typeorm.config';
|
import { typeOrmConfigFactory } from './config/typeorm.config';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
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 { 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 { 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 { 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 { 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';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -22,9 +36,27 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
envFilePath: ['.env'],
|
envFilePath: ['.env'],
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forRootAsync(typeOrmConfigFactory),
|
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,
|
UsersModule,
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
|
MediaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
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: {
|
database: {
|
||||||
url: process.env.DB_URL,
|
url: process.env.DB_URL,
|
||||||
|
ssl: (process.env.DB_SSL ?? 'false') === 'true',
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
url: process.env.REDIS_URL,
|
url: process.env.REDIS_URL,
|
||||||
@@ -16,6 +17,10 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
sms: {
|
sms: {
|
||||||
apiKey: process.env.SMS_API_KEY,
|
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: {
|
otp: {
|
||||||
ttlSeconds: parseInt(process.env.OTP_TTL_SECONDS ?? '120', 10),
|
ttlSeconds: parseInt(process.env.OTP_TTL_SECONDS ?? '120', 10),
|
||||||
@@ -23,8 +28,12 @@ export default () => ({
|
|||||||
minio: {
|
minio: {
|
||||||
endpoint: process.env.MINIO_ENDPOINT,
|
endpoint: process.env.MINIO_ENDPOINT,
|
||||||
port: parseInt(process.env.MINIO_PORT ?? '9000', 10),
|
port: parseInt(process.env.MINIO_PORT ?? '9000', 10),
|
||||||
|
useSsl: (process.env.MINIO_USE_SSL ?? 'false') === 'true',
|
||||||
accessKey: process.env.MINIO_ACCESS_KEY,
|
accessKey: process.env.MINIO_ACCESS_KEY,
|
||||||
secretKey: process.env.MINIO_SECRET_KEY,
|
secretKey: process.env.MINIO_SECRET_KEY,
|
||||||
bucket: process.env.MINIO_BUCKET,
|
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()
|
@IsString()
|
||||||
DB_URL!: string;
|
DB_URL!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
DB_SSL?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
REDIS_URL?: string;
|
REDIS_URL?: string;
|
||||||
@@ -34,9 +38,61 @@ class EnvironmentVariables {
|
|||||||
@IsString()
|
@IsString()
|
||||||
SMS_API_KEY!: string;
|
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()
|
@IsOptional()
|
||||||
@IsNumberString()
|
@IsNumberString()
|
||||||
OTP_TTL_SECONDS?: string;
|
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>) {
|
export function validateEnv(config: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModuleAsyncOptions, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
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 { 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 { 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 { 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 = (
|
export const buildTypeOrmOptions = (
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
): TypeOrmModuleOptions => ({
|
): TypeOrmModuleOptions => {
|
||||||
|
const sslEnabled = configService.get<boolean>('database.ssl', false);
|
||||||
|
|
||||||
|
return {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
url: configService.get<string>('database.url'),
|
url: configService.get<string>('database.url'),
|
||||||
entities: [User, Product, Category],
|
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,
|
autoLoadEntities: false,
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const typeOrmConfigFactory: TypeOrmModuleAsyncOptions = {
|
export const typeOrmConfigFactory: TypeOrmModuleAsyncOptions = {
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
10
src/main.ts
10
src/main.ts
@@ -1,5 +1,6 @@
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory, Reflector } from '@nestjs/core';
|
import { NestFactory, Reflector } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
|
|
||||||
@@ -20,6 +21,15 @@ async function bootstrap() {
|
|||||||
);
|
);
|
||||||
app.useGlobalInterceptors(new ResponseInterceptor(reflector));
|
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);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { Permissions } from '../../common/decorators/permissions.decorator';
|
import { Permissions } from '../../common/decorators/permissions.decorator';
|
||||||
import { Roles } from '../../common/decorators/roles.decorator';
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
import { UserRole } from '../users/enums/user-role.enum';
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginPasswordDto } from './dto/login-password.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { RegisterPasswordDto } from './dto/register-password.dto';
|
||||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
|
||||||
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
@@ -28,6 +32,16 @@ export class AuthController {
|
|||||||
return this.authService.requestOtp(dto.phone, dto.fullName);
|
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')
|
@Post('otp/verify')
|
||||||
verifyOtp(@Body() dto: VerifyOtpDto) {
|
verifyOtp(@Body() dto: VerifyOtpDto) {
|
||||||
return this.authService.verifyOtp(dto.phone, dto.otp);
|
return this.authService.verifyOtp(dto.phone, dto.otp);
|
||||||
@@ -39,12 +53,14 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
logout(@Req() request: Request & { user: JwtPayload }) {
|
logout(@Req() request: Request & { user: JwtPayload }) {
|
||||||
return this.authService.logout(request.user.sub);
|
return this.authService.logout(request.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
@Permissions('users.manage')
|
@Permissions('users.manage')
|
||||||
@Get('me/admin-check')
|
@Get('me/admin-check')
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
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 { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
@@ -14,6 +18,7 @@ import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
TypeOrmModule.forFeature([AuthOtp, UserSession]),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
@@ -23,7 +28,7 @@ import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy, RolesGuard, PermissionsGuard],
|
providers: [AuthService, SmsService, JwtStrategy, RolesGuard, PermissionsGuard],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { StringValue } from 'ms';
|
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 { User } from '../users/entities/user.entity';
|
||||||
|
import { UserLevel } from '../users/enums/user-level.enum';
|
||||||
import { UserRole } from '../users/enums/user-role.enum';
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { UsersService } from '../users/users.service';
|
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 { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -18,43 +26,109 @@ export class AuthService {
|
|||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
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) {
|
async requestOtp(phone: string, fullName?: string) {
|
||||||
const user = await this.usersService.findOrCreateByPhone(phone, fullName);
|
const user = await this.usersService.findOrCreateByPhone(phone, fullName);
|
||||||
const otpCode = this.generateOtp();
|
const otpCode = this.generateOtp();
|
||||||
const ttlSeconds = this.configService.get<number>('otp.ttlSeconds', 120);
|
const ttlSeconds = this.configService.get<number>('otp.ttlSeconds', 120);
|
||||||
|
const otp = this.authOtpsRepository.create({
|
||||||
user.otpCode = otpCode;
|
phone: user.phone,
|
||||||
user.otpExpiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
codeHash: await bcrypt.hash(otpCode, 10),
|
||||||
await this.usersService.save(user);
|
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 {
|
return {
|
||||||
message: 'OTP generated successfully',
|
message: 'OTP generated successfully',
|
||||||
expiresInSeconds: ttlSeconds,
|
expiresInSeconds: ttlSeconds,
|
||||||
phone,
|
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) {
|
async verifyOtp(phone: string, otp: string) {
|
||||||
const user = await this.usersService.findByPhone(phone);
|
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');
|
throw new UnauthorizedException('OTP not requested');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.otpExpiresAt.getTime() < Date.now()) {
|
if (otpRecord.expiresAt.getTime() < Date.now()) {
|
||||||
throw new UnauthorizedException('OTP expired');
|
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');
|
throw new BadRequestException('Invalid OTP');
|
||||||
}
|
}
|
||||||
|
|
||||||
user.isVerified = true;
|
user.isVerified = true;
|
||||||
user.otpCode = null;
|
otpRecord.usedAt = new Date();
|
||||||
user.otpExpiresAt = null;
|
await Promise.all([
|
||||||
|
this.usersService.save(user),
|
||||||
|
this.authOtpsRepository.save(otpRecord),
|
||||||
|
]);
|
||||||
|
|
||||||
const tokens = await this.issueTokens(user);
|
const tokens = await this.issueTokens(user);
|
||||||
await this.storeRefreshToken(user, tokens.refreshToken);
|
await this.storeRefreshToken(user, tokens.refreshToken);
|
||||||
@@ -72,17 +146,28 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersService.findByPhone(payload.phone);
|
const user = await this.usersService.findByPhone(payload.phone);
|
||||||
|
if (!user) {
|
||||||
if (!user?.refreshTokenHash) {
|
|
||||||
throw new UnauthorizedException('Refresh token not found');
|
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');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validSession.revokedAt = new Date();
|
||||||
|
await this.userSessionsRepository.save(validSession);
|
||||||
|
|
||||||
const tokens = await this.issueTokens(user);
|
const tokens = await this.issueTokens(user);
|
||||||
await this.storeRefreshToken(user, tokens.refreshToken);
|
await this.storeRefreshToken(user, tokens.refreshToken);
|
||||||
|
|
||||||
@@ -91,18 +176,24 @@ export class AuthService {
|
|||||||
|
|
||||||
async logout(userId: string) {
|
async logout(userId: string) {
|
||||||
const user = await this.findUserById(userId);
|
const user = await this.findUserById(userId);
|
||||||
user.refreshTokenHash = null;
|
await this.userSessionsRepository
|
||||||
await this.usersService.save(user);
|
.createQueryBuilder()
|
||||||
|
.update(UserSession)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where('userId = :userId', { userId: user.id })
|
||||||
|
.andWhere('revoked_at IS NULL')
|
||||||
|
.execute();
|
||||||
|
|
||||||
return { message: 'Logged out successfully' };
|
return { message: 'Logged out successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async issueTokens(user: User) {
|
private async issueTokens(user: User) {
|
||||||
|
const currentLevel = user.loyaltyProfile?.currentLevel ?? UserLevel.BRONZE;
|
||||||
const accessPayload: JwtPayload = {
|
const accessPayload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
level: user.level,
|
level: currentLevel,
|
||||||
permissions: this.resolvePermissions(user),
|
permissions: this.resolvePermissions(user),
|
||||||
type: 'access',
|
type: 'access',
|
||||||
};
|
};
|
||||||
@@ -131,14 +222,19 @@ export class AuthService {
|
|||||||
phone: user.phone,
|
phone: user.phone,
|
||||||
fullName: user.fullName,
|
fullName: user.fullName,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
level: user.level,
|
level: currentLevel,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async storeRefreshToken(user: User, refreshToken: string) {
|
private async storeRefreshToken(user: User, refreshToken: string) {
|
||||||
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
const refreshTtl = this.configService.getOrThrow<StringValue>('jwt.refreshTtl');
|
||||||
await this.usersService.save(user);
|
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() {
|
private generateOtp() {
|
||||||
@@ -147,7 +243,13 @@ export class AuthService {
|
|||||||
|
|
||||||
private resolvePermissions(user: User) {
|
private resolvePermissions(user: User) {
|
||||||
if (user.role === UserRole.ADMIN) {
|
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) {
|
if (user.role === UserRole.AGENT) {
|
||||||
@@ -166,4 +268,37 @@ export class AuthService {
|
|||||||
|
|
||||||
return user;
|
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { 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 { Product } from './entities/product.entity';
|
||||||
|
import { ProductReview } from './entities/product-review.entity';
|
||||||
|
import { ProductsController } from './products.controller';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Category, Product])],
|
imports: [
|
||||||
exports: [TypeOrmModule],
|
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 {}
|
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,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
|
ManyToMany,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { ProductType } from '../enums/product-type.enum';
|
||||||
|
import { Product } from './product.entity';
|
||||||
|
|
||||||
@Entity({ name: 'categories' })
|
@Entity({ name: 'categories' })
|
||||||
export class Category {
|
export class Category {
|
||||||
@@ -19,6 +22,15 @@ export class Category {
|
|||||||
@Column({ unique: true, length: 180 })
|
@Column({ unique: true, length: 180 })
|
||||||
slug: string;
|
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, {
|
@ManyToOne(() => Category, (category) => category.children, {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
onDelete: 'SET NULL',
|
onDelete: 'SET NULL',
|
||||||
@@ -28,6 +40,12 @@ export class Category {
|
|||||||
@OneToMany(() => Category, (category) => category.parent)
|
@OneToMany(() => Category, (category) => category.parent)
|
||||||
children: Category[];
|
children: Category[];
|
||||||
|
|
||||||
|
@OneToMany(() => Product, (product) => product.primaryCategory)
|
||||||
|
primaryProducts: Product[];
|
||||||
|
|
||||||
|
@ManyToMany(() => Product, (product) => product.categories)
|
||||||
|
products: Product[];
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
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,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { ProductStatus } from '../enums/product-status.enum';
|
||||||
import { ProductType } from '../enums/product-type.enum';
|
import { ProductType } from '../enums/product-type.enum';
|
||||||
|
import { Brand } from './brand.entity';
|
||||||
import { Category } from './category.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' })
|
@Entity({ name: 'products' })
|
||||||
export class Product {
|
export class Product {
|
||||||
@@ -18,6 +27,14 @@ export class Product {
|
|||||||
@Column({ unique: true, length: 80 })
|
@Column({ unique: true, length: 80 })
|
||||||
sku: string;
|
sku: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ length: 160 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ unique: true, length: 180 })
|
||||||
|
slug: string;
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column({ name: 'technical_code', length: 120 })
|
@Column({ name: 'technical_code', length: 120 })
|
||||||
technicalCode: string;
|
technicalCode: string;
|
||||||
@@ -25,6 +42,12 @@ export class Product {
|
|||||||
@Column({ length: 120 })
|
@Column({ length: 120 })
|
||||||
brand: string;
|
brand: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Brand, (brand) => brand.products, {
|
||||||
|
nullable: true,
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
})
|
||||||
|
brandEntity?: Brand | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'base_price_usd',
|
name: 'base_price_usd',
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
@@ -37,23 +60,103 @@ export class Product {
|
|||||||
})
|
})
|
||||||
basePriceUSD: number;
|
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 })
|
@Column({ type: 'int', default: 0 })
|
||||||
stock: number;
|
stock: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
featured: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ProductType,
|
enum: ProductType,
|
||||||
})
|
})
|
||||||
type: 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;
|
threeDModelUrl?: string | null;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
@Column({ type: 'jsonb', name: 'image_gallery_urls', default: () => "'[]'" })
|
||||||
attributes: Record<string, unknown>;
|
imageGalleryUrls: string[];
|
||||||
|
|
||||||
@ManyToOne(() => Category, { nullable: true, onDelete: 'SET NULL' })
|
@Column({ type: 'jsonb', default: () => "'[]'" })
|
||||||
category?: Category | null;
|
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' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
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,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} 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 { UserRole } from '../enums/user-role.enum';
|
||||||
|
import { LoyaltyProfile } from './loyalty-profile.entity';
|
||||||
|
import { Wallet } from './wallet.entity';
|
||||||
|
|
||||||
@Entity({ name: 'users' })
|
@Entity({ name: 'users' })
|
||||||
export class User {
|
export class User {
|
||||||
@@ -16,6 +20,9 @@ export class User {
|
|||||||
@Column({ unique: true, length: 20 })
|
@Column({ unique: true, length: 20 })
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', unique: true, nullable: true, length: 50 })
|
||||||
|
username?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'full_name', length: 150 })
|
@Column({ name: 'full_name', length: 150 })
|
||||||
fullName: string;
|
fullName: string;
|
||||||
|
|
||||||
@@ -26,37 +33,22 @@ export class User {
|
|||||||
})
|
})
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: UserLevel,
|
|
||||||
default: UserLevel.BRONZE,
|
|
||||||
})
|
|
||||||
level: UserLevel;
|
|
||||||
|
|
||||||
@Column({ name: 'is_verified', default: false })
|
@Column({ name: 'is_verified', default: false })
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({ type: 'varchar', name: 'password_hash', nullable: true, length: 255 })
|
||||||
name: 'wallet_balance',
|
passwordHash?: string | null;
|
||||||
type: 'numeric',
|
|
||||||
precision: 12,
|
@OneToOne(() => Wallet, (wallet) => wallet.user, { eager: true })
|
||||||
scale: 2,
|
wallet: Wallet;
|
||||||
default: 0,
|
|
||||||
transformer: {
|
@OneToOne(() => LoyaltyProfile, (loyaltyProfile) => loyaltyProfile.user, {
|
||||||
to: (value: number) => value,
|
eager: true,
|
||||||
from: (value: string) => Number(value),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
walletBalance: number;
|
loyaltyProfile: LoyaltyProfile;
|
||||||
|
|
||||||
@Column({ name: 'otp_code', nullable: true, length: 10 })
|
@OneToMany(() => UserSession, (session) => session.user)
|
||||||
otpCode?: string | null;
|
sessions: UserSession[];
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
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 { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { LoyaltyProfile } from './entities/loyalty-profile.entity';
|
||||||
import { User } from './entities/user.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';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
User,
|
||||||
|
Wallet,
|
||||||
|
WalletTransaction,
|
||||||
|
LoyaltyProfile,
|
||||||
|
UserLevelHistory,
|
||||||
|
]),
|
||||||
|
],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,39 +1,93 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { LoyaltyProfile } from './entities/loyalty-profile.entity';
|
||||||
import { User } from './entities/user.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 { UserRole } from './enums/user-role.enum';
|
||||||
|
import { UserLevel } from './enums/user-level.enum';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly usersRepository: Repository<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) {
|
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) {
|
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) {
|
async findOrCreateByPhone(phone: string, fullName?: string) {
|
||||||
let user = await this.findByPhone(phone);
|
let user = await this.findByPhone(phone);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = this.usersRepository.create({
|
user = await this.create({
|
||||||
phone,
|
phone,
|
||||||
fullName: fullName ?? phone,
|
fullName: fullName ?? phone,
|
||||||
role: UserRole.USER,
|
role: UserRole.USER,
|
||||||
});
|
});
|
||||||
user = await this.usersRepository.save(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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) {
|
async save(user: User) {
|
||||||
return this.usersRepository.save(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