update product

This commit is contained in:
2026-03-26 11:49:21 +03:00
parent 7109325bff
commit f2b5f7006b
76 changed files with 5252 additions and 139 deletions

View File

@@ -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

BIN
1.zip Normal file

Binary file not shown.

View File

@@ -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
``` ```

View File

@@ -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
View 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
View 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
View 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {}

View File

@@ -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],

View 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;
}

View File

@@ -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,
}, },
}); });

View File

@@ -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>) {

View File

@@ -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],

View File

@@ -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);
} }

View File

@@ -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')

View File

@@ -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 {}

View File

@@ -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];
}
} }

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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');
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}
}

View File

@@ -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 {}

View 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);
}
}

View 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);
}
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateAttributeDefinitionDto } from './create-attribute-definition.dto';
export class UpdateAttributeDefinitionDto extends PartialType(
CreateAttributeDefinitionDto,
) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateBrandDto } from './create-brand.dto';
export class UpdateBrandDto extends PartialType(CreateBrandDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateProductDto } from './create-product.dto';
export class UpdateProductDto extends PartialType(CreateProductDto) {}

View 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;
}

View 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;
}

View File

@@ -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;

View File

@@ -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;
}

View 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;
}

View 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;
}

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
export enum AttributeDataType {
TEXT = 'text',
NUMBER = 'number',
BOOLEAN = 'boolean',
SELECT = 'select',
MULTISELECT = 'multiselect',
JSON = 'json',
}

View File

@@ -0,0 +1,5 @@
export enum ProductStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}

View 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);
}
}

View 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);
}
}
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { UploadMediaDto } from './upload-media.dto';
export class UpdateMediaAssetDto extends PartialType(UploadMediaDto) {}

View 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>;
}

View 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;
}

View File

@@ -0,0 +1,8 @@
export enum MediaSection {
IMAGE = 'image',
GALLERY = 'gallery',
AUDIO = 'audio',
VIDEO = 'video',
MODEL_3D = 'model3d',
DOCUMENT = 'document',
}

View 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);
}
}

View 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 {}

View 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';
}
}

View 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 {}

View 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}`;
}
}

View 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;
}

View 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;
}

View File

@@ -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;

View 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;
}

View 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;
}

View File

@@ -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],
}) })

View File

@@ -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
View File

@@ -0,0 +1,35 @@
[Nest] 24228 - 03/24/2026, 1:50:10 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (1)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:50:34 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (2)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:50:58 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (3)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:51:22 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (4)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:51:46 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (5)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:52:10 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (6)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:52:34 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (7)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:52:58 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (8)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:53:22 PM  ERROR [TypeOrmModule] Unable to connect to the database. Retrying (9)...
Error: connect ETIMEDOUT 62.3.14.124:6986
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16)
[Nest] 24228 - 03/24/2026, 1:53:22 PM  ERROR [ExceptionHandler] Error: connect ETIMEDOUT 62.3.14.124:6986
 at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1636:16) {
errno: -4039,
code: 'ETIMEDOUT',
syscall: 'connect',
address: '62.3.14.124',
port: 6986
}

13
tmp-start.out Normal file
View File

@@ -0,0 +1,13 @@
> parsshop-back@0.1.0 start
> nest start
[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [NestFactory] Starting Nest application...
[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] TypeOrmModule dependencies initialized +19ms
[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] PassportModule dependencies initialized +2ms
[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] ConfigHostModule dependencies initialized +3ms
[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] AppModule dependencies initialized +4ms
[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +1ms
[Nest] 24228 - 03/24/2026, 1:49:48 PM  LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 24228 - 03/24/2026, 1:49:49 PM  LOG [InstanceLoader] StorageModule dependencies initialized +45ms
[Nest] 24228 - 03/24/2026, 1:49:49 PM  LOG [InstanceLoader] JwtModule dependencies initialized +1ms