Initial commit: Mana Collection NestJS backend.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
# PostgreSQL (Docker Compose)
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5436
|
||||||
|
POSTGRES_USER=mana
|
||||||
|
POSTGRES_PASSWORD=mana_secret
|
||||||
|
POSTGRES_DB=mana_collection
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://mana:mana_secret@localhost:5436/mana_collection
|
||||||
|
|
||||||
|
# API
|
||||||
|
PORT=3002
|
||||||
|
PUBLIC_SITE_ORIGIN=http://mana-collection.com:3000
|
||||||
|
CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000,http://mana-collection.com:3000,http://www.mana-collection.com:3000,http://store.mana-collection.com:3000,http://customer.mana-collection.com:3000,http://admin.mana-collection.com:3000,https://manacollections.ir,https://store.manacollections.ir,https://customer.manacollections.ir,https://admin.manacollections.ir
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_ACCESS_SECRET=change-me-mana-access-secret-min-32-chars
|
||||||
|
JWT_REFRESH_SECRET=change-me-mana-refresh-secret-min-32-chars
|
||||||
|
JWT_ACCESS_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Object storage (same Parspack bucket as Mado/Balout — prefix mana-collection/)
|
||||||
|
STORAGE_DISK=s3
|
||||||
|
S3_ENDPOINT=https://c804387.parspack.net
|
||||||
|
S3_BUCKET=c804387
|
||||||
|
S3_PUBLIC_URL=https://c804387.parspack.net/c804387
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
|
MEDIA_MAX_FILE_SIZE_MB=10
|
||||||
|
|
||||||
|
# Meshkee SMS (server-side only — never expose to frontend)
|
||||||
|
# Add manacollections.ir to Meshkee SMS_PARTNERS with this API key
|
||||||
|
MESHKEE_SMS_URL=https://api.meshkee.com/api/v1/public/sms/send
|
||||||
|
MESHKEE_SMS_API_KEY=
|
||||||
|
MESHKEE_SMS_DOMAIN=manacollections.ir
|
||||||
|
# Comma-separated admin mobiles for alerts
|
||||||
|
SMS_NOTIFY_MOBILES=
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
scripts/data/
|
||||||
|
|
||||||
|
.idea/
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Mana Collection Backend — Context
|
||||||
|
|
||||||
|
## Product
|
||||||
|
|
||||||
|
Multi-seller fashion marketplace. Each seller is a `Store` with one `STORE_ADMIN` owner.
|
||||||
|
|
||||||
|
Future dashboards:
|
||||||
|
|
||||||
|
- `store.manacollections.ir` — store admin
|
||||||
|
- `customer.manacollections.ir` — customer
|
||||||
|
- `admin.manacollections.ir` — super admin
|
||||||
|
|
||||||
|
## Patterns (from Mado Motor)
|
||||||
|
|
||||||
|
- Nest 11, Prisma 6, bcrypt, passport-jwt
|
||||||
|
- Meshkee SMS (`MESHKEE_SMS_*`)
|
||||||
|
- Parspack S3 with project prefix `mana-collection/` (Mado uses `mado-motor/`)
|
||||||
|
- Opaque refresh tokens (hashed in DB), JWT access with `{ sub, role, storeId? }`
|
||||||
|
- OTP: SHA-256 hash, 6 digits, 5 min TTL, 60s resend, 5 attempts
|
||||||
|
|
||||||
|
## Store lifecycle
|
||||||
|
|
||||||
|
1. Public `storeRegister` OTP → creates `User(STORE_ADMIN)` + `Store(pending)`
|
||||||
|
2. Super admin sets status `active` (or `rejected` with reason)
|
||||||
|
3. Pending store: only `GET/PATCH /stores/me`
|
||||||
|
4. Active store: full products/categories/brands/blogs/orders/customers CRUD
|
||||||
|
|
||||||
|
## Multi-store orders
|
||||||
|
|
||||||
|
`Order` can span multiple stores via `OrderItem.storeId` and per-store `OrderStoreFulfillment`. Store admins see only their items + update their fulfillment status. Super admin manages overall `Order.status`.
|
||||||
|
|
||||||
|
## Catalog variants
|
||||||
|
|
||||||
|
Categories hold `colors[]`, `sizes[]`, `packages[]` (catalog ids). Products have nested `ProductVariant` rows with `colorId` / `sizeId` / `packageId` + prices. Static catalogs exposed at `/catalog`.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Mana Collection API
|
||||||
|
|
||||||
|
NestJS 11 + Prisma 6 + PostgreSQL multi-seller marketplace backend for **Mana Collection**.
|
||||||
|
|
||||||
|
- Public site: https://manacollections.ir
|
||||||
|
- API prefix: `/api/v1`
|
||||||
|
- Default port: `3002`
|
||||||
|
- Postgres Docker port: `5436`
|
||||||
|
- S3 prefix: `mana-collection/`
|
||||||
|
- SMS domain: `manacollections.ir`
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# fill JWT secrets, S3 keys, MESHKEE_SMS_API_KEY
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run db:up
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
npm run seed:mana-store -- --phone 09120000000 --password mana1234
|
||||||
|
# optional super admin:
|
||||||
|
# npm run seed:mana-store -- --phone 09120000000 --password mana1234 --super-phone 09127004945 --super-password admin1234
|
||||||
|
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
API: http://localhost:3002/api/v1
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `npm run db:up` | Start Postgres (port 5436) |
|
||||||
|
| `npm run prisma:migrate` | Dev migrations |
|
||||||
|
| `npm run prisma:deploy` | Apply migrations |
|
||||||
|
| `npm run create-super-admin` | Create `SUPER_ADMIN` |
|
||||||
|
| `npm run seed:mana-store` | Seed Mana Collection store + `STORE_ADMIN` |
|
||||||
|
| `npm run send-sms` | Send Meshkee SMS |
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
- `CUSTOMER` — public registration
|
||||||
|
- `STORE_ADMIN` — owns one store; full catalog CRUD only when store is `active`
|
||||||
|
- `SUPER_ADMIN` — approve stores, all orders, all customers
|
||||||
|
|
||||||
|
## Key routes
|
||||||
|
|
||||||
|
- Auth: `POST /auth/login`, `/auth/register/*`, `/auth/refresh`, `GET /auth/me`
|
||||||
|
- Store register: `POST /public/stores/register/send-code`, `/verify`
|
||||||
|
- Store me: `GET|PATCH /stores/me`
|
||||||
|
- Admin stores: `GET /admin/stores`, `PATCH /admin/stores/:id/status`
|
||||||
|
- Products / categories / brands / blogs / customers / orders (JWT + role)
|
||||||
|
- Public: `GET /public/products`, `/public/products/:id`, `/public/stores`, `/public/blogs`
|
||||||
|
- Profile: `GET|PATCH /profile`, addresses CRUD
|
||||||
|
- Catalog: `GET /catalog` (colors / sizes / packages)
|
||||||
|
- Media: `POST /media/upload?kind=main|gallery|blog|brand|store|avatar|temp`
|
||||||
|
|
||||||
|
See `CONTEXT.md` for domain notes.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: mana-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-5436}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-mana}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mana_secret}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-mana_collection}
|
||||||
|
volumes:
|
||||||
|
- mana_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER:-mana} -d ${POSTGRES_DB:-mana_collection}",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mana_postgres_data:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5601
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "mana-collection-api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
|
"db:up": "docker compose up -d",
|
||||||
|
"db:down": "docker compose down",
|
||||||
|
"create-super-admin": "ts-node -r tsconfig-paths/register scripts/create-super-admin.ts",
|
||||||
|
"seed:mana-store": "ts-node -r tsconfig-paths/register scripts/seed-mana-store.ts",
|
||||||
|
"send-sms": "ts-node -r tsconfig-paths/register scripts/send-sms.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1101.0",
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('CUSTOMER', 'STORE_ADMIN', 'SUPER_ADMIN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StoreStatus" AS ENUM ('pending', 'active', 'rejected', 'suspended');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrderStatus" AS ENUM ('pending', 'confirmed', 'preparing', 'shipped', 'delivered', 'cancelled');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrderStoreStatus" AS ENUM ('pending', 'confirmed', 'preparing', 'shipped', 'delivered', 'cancelled');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SmsOtpPurpose" AS ENUM ('register', 'login', 'resetPassword', 'storeRegister');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"cellNumber" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"instagram" TEXT,
|
||||||
|
"about" TEXT,
|
||||||
|
"avatarUrl" TEXT,
|
||||||
|
"avatarKey" TEXT,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'CUSTOMER',
|
||||||
|
"disabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserAddress" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL DEFAULT '',
|
||||||
|
"province" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"fullAddress" TEXT NOT NULL,
|
||||||
|
"landline" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "UserAddress_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SmsOtp" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"cellNumber" TEXT NOT NULL,
|
||||||
|
"purpose" "SmsOtpPurpose" NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"payload" JSONB,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsOtp_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Store" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||||
|
"description" TEXT,
|
||||||
|
"logoUrl" TEXT,
|
||||||
|
"logoKey" TEXT,
|
||||||
|
"status" "StoreStatus" NOT NULL DEFAULT 'pending',
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"rejectionReason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Store_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"colors" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"sizes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"packages" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Brand" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"imageKey" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Brand_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Product" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"brandId" TEXT,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL DEFAULT '',
|
||||||
|
"price" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"discountPrice" INTEGER,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"imageKey" TEXT,
|
||||||
|
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductVariant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"colorId" TEXT NOT NULL DEFAULT '',
|
||||||
|
"sizeId" TEXT NOT NULL DEFAULT '',
|
||||||
|
"packageId" TEXT NOT NULL DEFAULT '',
|
||||||
|
"price" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"discountPrice" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BlogPost" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"summary" TEXT NOT NULL DEFAULT '',
|
||||||
|
"body" TEXT NOT NULL DEFAULT '',
|
||||||
|
"author" TEXT NOT NULL DEFAULT '',
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"imageKey" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"metaTitle" TEXT,
|
||||||
|
"metaDescription" TEXT,
|
||||||
|
"slug" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Order" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT,
|
||||||
|
"customerName" TEXT NOT NULL,
|
||||||
|
"customerPhone" TEXT NOT NULL,
|
||||||
|
"status" "OrderStatus" NOT NULL DEFAULT 'pending',
|
||||||
|
"itemCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalAmount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"note" TEXT NOT NULL DEFAULT '',
|
||||||
|
"shippingAddress" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT,
|
||||||
|
"nameFa" TEXT NOT NULL,
|
||||||
|
"variantLabel" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"unitPrice" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderStoreFulfillment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"status" "OrderStoreStatus" NOT NULL DEFAULT 'pending',
|
||||||
|
|
||||||
|
CONSTRAINT "OrderStoreFulfillment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_cellNumber_key" ON "User"("cellNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_disabled_idx" ON "User"("disabled");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserAddress_userId_idx" ON "UserAddress"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefreshToken_tokenHash_idx" ON "RefreshToken"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsOtp_cellNumber_purpose_idx" ON "SmsOtp"("cellNumber", "purpose");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsOtp_expiresAt_idx" ON "SmsOtp"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Store_slug_key" ON "Store"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Store_ownerId_key" ON "Store"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Store_status_idx" ON "Store"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Category_storeId_idx" ON "Category"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Category_parentId_idx" ON "Category"("parentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Category_sortOrder_idx" ON "Category"("sortOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Brand_storeId_idx" ON "Brand"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_storeId_idx" ON "Product"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_brandId_idx" ON "Product"("brandId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_price_idx" ON "Product"("price");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Product_published_idx" ON "Product"("published");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductVariant_productId_idx" ON "ProductVariant"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BlogPost_storeId_idx" ON "BlogPost"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BlogPost_published_idx" ON "BlogPost"("published");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BlogPost_slug_idx" ON "BlogPost"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_customerId_idx" ON "Order"("customerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_status_idx" ON "Order"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_createdAt_idx" ON "Order"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItem_storeId_idx" ON "OrderItem"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItem_productId_idx" ON "OrderItem"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderStoreFulfillment_storeId_idx" ON "OrderStoreFulfillment"("storeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderStoreFulfillment_status_idx" ON "OrderStoreFulfillment"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "OrderStoreFulfillment_orderId_storeId_key" ON "OrderStoreFulfillment"("orderId", "storeId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserAddress" ADD CONSTRAINT "UserAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Store" ADD CONSTRAINT "Store_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Category" ADD CONSTRAINT "Category_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Brand" ADD CONSTRAINT "Brand_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Product" ADD CONSTRAINT "Product_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BlogPost" ADD CONSTRAINT "BlogPost_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderStoreFulfillment" ADD CONSTRAINT "OrderStoreFulfillment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OrderStoreFulfillment" ADD CONSTRAINT "OrderStoreFulfillment_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "summary" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "description" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "galleryUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "galleryKeys" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Prisma migration lock
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
CUSTOMER
|
||||||
|
STORE_ADMIN
|
||||||
|
SUPER_ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreStatus {
|
||||||
|
pending
|
||||||
|
active
|
||||||
|
rejected
|
||||||
|
suspended
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
pending
|
||||||
|
confirmed
|
||||||
|
preparing
|
||||||
|
shipped
|
||||||
|
delivered
|
||||||
|
cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStoreStatus {
|
||||||
|
pending
|
||||||
|
confirmed
|
||||||
|
preparing
|
||||||
|
shipped
|
||||||
|
delivered
|
||||||
|
cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SmsOtpPurpose {
|
||||||
|
register
|
||||||
|
login
|
||||||
|
resetPassword
|
||||||
|
storeRegister
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
cellNumber String @unique
|
||||||
|
passwordHash String
|
||||||
|
email String?
|
||||||
|
instagram String?
|
||||||
|
about String?
|
||||||
|
avatarUrl String?
|
||||||
|
avatarKey String?
|
||||||
|
role UserRole @default(CUSTOMER)
|
||||||
|
disabled Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
refreshTokens RefreshToken[]
|
||||||
|
addresses UserAddress[]
|
||||||
|
ownedStore Store?
|
||||||
|
orders Order[] @relation("CustomerOrders")
|
||||||
|
|
||||||
|
@@index([role])
|
||||||
|
@@index([disabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserAddress {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
label String @default("")
|
||||||
|
province String
|
||||||
|
city String
|
||||||
|
fullAddress String
|
||||||
|
landline String?
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tokenHash String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([tokenHash])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsOtp {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
cellNumber String
|
||||||
|
purpose SmsOtpPurpose
|
||||||
|
codeHash String
|
||||||
|
payload Json?
|
||||||
|
attempts Int @default(0)
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([cellNumber, purpose])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Store {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
nameFa String
|
||||||
|
nameEn String @default("")
|
||||||
|
description String?
|
||||||
|
logoUrl String?
|
||||||
|
logoKey String?
|
||||||
|
status StoreStatus @default(pending)
|
||||||
|
ownerId String @unique
|
||||||
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
rejectionReason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
products Product[]
|
||||||
|
categories Category[]
|
||||||
|
brands Brand[]
|
||||||
|
blogs BlogPost[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
fulfillments OrderStoreFulfillment[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
parentId String?
|
||||||
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||||
|
children Category[] @relation("CategoryTree")
|
||||||
|
nameFa String
|
||||||
|
nameEn String @default("")
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
colors String[] @default([])
|
||||||
|
sizes String[] @default([])
|
||||||
|
packages String[] @default([])
|
||||||
|
products Product[]
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([parentId])
|
||||||
|
@@index([sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Brand {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
nameFa String
|
||||||
|
nameEn String @default("")
|
||||||
|
imageUrl String?
|
||||||
|
imageKey String?
|
||||||
|
products Product[]
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
categoryId String?
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
brandId String?
|
||||||
|
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||||
|
nameFa String
|
||||||
|
nameEn String @default("")
|
||||||
|
summary String @default("")
|
||||||
|
description String @default("")
|
||||||
|
price Int @default(0)
|
||||||
|
discountPrice Int?
|
||||||
|
imageUrl String?
|
||||||
|
imageKey String?
|
||||||
|
galleryUrls String[] @default([])
|
||||||
|
galleryKeys String[] @default([])
|
||||||
|
tags String[] @default([])
|
||||||
|
published Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
variants ProductVariant[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([brandId])
|
||||||
|
@@index([price])
|
||||||
|
@@index([published])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductVariant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
colorId String @default("")
|
||||||
|
sizeId String @default("")
|
||||||
|
packageId String @default("")
|
||||||
|
price Int @default(0)
|
||||||
|
discountPrice Int?
|
||||||
|
|
||||||
|
@@index([productId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BlogPost {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
summary String @default("")
|
||||||
|
body String @default("")
|
||||||
|
author String @default("")
|
||||||
|
imageUrl String?
|
||||||
|
imageKey String?
|
||||||
|
published Boolean @default(true)
|
||||||
|
metaTitle String?
|
||||||
|
metaDescription String?
|
||||||
|
slug String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([published])
|
||||||
|
@@index([slug])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
customerId String?
|
||||||
|
customer User? @relation("CustomerOrders", fields: [customerId], references: [id], onDelete: SetNull)
|
||||||
|
customerName String
|
||||||
|
customerPhone String
|
||||||
|
status OrderStatus @default(pending)
|
||||||
|
itemCount Int @default(0)
|
||||||
|
totalAmount Int @default(0)
|
||||||
|
note String @default("")
|
||||||
|
shippingAddress Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
items OrderItem[]
|
||||||
|
fulfillments OrderStoreFulfillment[]
|
||||||
|
|
||||||
|
@@index([customerId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||||
|
productId String?
|
||||||
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
|
nameFa String
|
||||||
|
variantLabel String?
|
||||||
|
quantity Int @default(1)
|
||||||
|
unitPrice Int @default(0)
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([productId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderStoreFulfillment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||||
|
status OrderStoreStatus @default(pending)
|
||||||
|
|
||||||
|
@@unique([orderId, storeId])
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Create the first SUPER_ADMIN.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx ts-node -r tsconfig-paths/register scripts/create-super-admin.ts \
|
||||||
|
* --phone 09127004945 --password secret123 --first علیرضا --last حسنی
|
||||||
|
*/
|
||||||
|
import { PrismaClient, UserRole } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
throw new Error(`Missing --${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cellNumber = arg('phone');
|
||||||
|
const password = arg('password');
|
||||||
|
const firstName = arg('first', 'علیرضا');
|
||||||
|
const lastName = arg('last', 'حسنی');
|
||||||
|
|
||||||
|
if (!/^09\d{9}$/.test(cellNumber)) {
|
||||||
|
throw new Error('phone must match 09xxxxxxxxx');
|
||||||
|
}
|
||||||
|
if (password.length < 4) {
|
||||||
|
throw new Error('password min length 4');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { cellNumber } });
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`User already exists: ${cellNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
cellNumber,
|
||||||
|
passwordHash,
|
||||||
|
role: UserRole.SUPER_ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Created SUPER_ADMIN:', {
|
||||||
|
id: user.id,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Seed Mana Collection flagship store + STORE_ADMIN (and optional SUPER_ADMIN).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run seed:mana-store -- \
|
||||||
|
* --phone 09120000000 --password secret123 \
|
||||||
|
* --first مانا --last ادمین \
|
||||||
|
* [--super-phone 09127004945 --super-password secret123]
|
||||||
|
*/
|
||||||
|
import { PrismaClient, StoreStatus, UserRole } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string | undefined {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cellNumber = arg('phone', '09120000000')!;
|
||||||
|
const password = arg('password', 'mana1234')!;
|
||||||
|
const firstName = arg('first', 'مانا')!;
|
||||||
|
const lastName = arg('last', 'ادمین')!;
|
||||||
|
const superPhone = arg('super-phone');
|
||||||
|
const superPassword = arg('super-password', 'admin1234');
|
||||||
|
|
||||||
|
if (!/^09\d{9}$/.test(cellNumber)) {
|
||||||
|
throw new Error('phone must match 09xxxxxxxxx');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const storeAdmin = await prisma.user.upsert({
|
||||||
|
where: { cellNumber },
|
||||||
|
update: {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
passwordHash,
|
||||||
|
role: UserRole.STORE_ADMIN,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
cellNumber,
|
||||||
|
passwordHash,
|
||||||
|
role: UserRole.STORE_ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await prisma.store.upsert({
|
||||||
|
where: { slug: 'mana-collection' },
|
||||||
|
update: {
|
||||||
|
nameFa: 'مانا کالکشن',
|
||||||
|
nameEn: 'Mana Collection',
|
||||||
|
status: StoreStatus.active,
|
||||||
|
ownerId: storeAdmin.id,
|
||||||
|
rejectionReason: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
slug: 'mana-collection',
|
||||||
|
nameFa: 'مانا کالکشن',
|
||||||
|
nameEn: 'Mana Collection',
|
||||||
|
description: 'فروشگاه اصلی مانا کالکشن',
|
||||||
|
status: StoreStatus.active,
|
||||||
|
ownerId: storeAdmin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seeded STORE_ADMIN + store:', {
|
||||||
|
userId: storeAdmin.id,
|
||||||
|
cellNumber: storeAdmin.cellNumber,
|
||||||
|
storeId: store.id,
|
||||||
|
slug: store.slug,
|
||||||
|
status: store.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (superPhone) {
|
||||||
|
if (!/^09\d{9}$/.test(superPhone)) {
|
||||||
|
throw new Error('super-phone must match 09xxxxxxxxx');
|
||||||
|
}
|
||||||
|
const superHash = await bcrypt.hash(superPassword!, 10);
|
||||||
|
const superAdmin = await prisma.user.upsert({
|
||||||
|
where: { cellNumber: superPhone },
|
||||||
|
update: {
|
||||||
|
role: UserRole.SUPER_ADMIN,
|
||||||
|
passwordHash: superHash,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
firstName: 'سوپر',
|
||||||
|
lastName: 'ادمین',
|
||||||
|
cellNumber: superPhone,
|
||||||
|
passwordHash: superHash,
|
||||||
|
role: UserRole.SUPER_ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Seeded SUPER_ADMIN:', {
|
||||||
|
id: superAdmin.id,
|
||||||
|
cellNumber: superAdmin.cellNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Send an SMS via Meshkee (backend-only; never call from frontend).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run send-sms -- --to 09127004945 --message "متن پیام"
|
||||||
|
*/
|
||||||
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
loadEnv({ path: resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
throw new Error(`Missing --${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const to = arg('to');
|
||||||
|
const message = arg('message');
|
||||||
|
const apiUrl =
|
||||||
|
process.env.MESHKEE_SMS_URL ??
|
||||||
|
'https://api.meshkee.com/api/v1/public/sms/send';
|
||||||
|
const apiKey = process.env.MESHKEE_SMS_API_KEY;
|
||||||
|
const domain = process.env.MESHKEE_SMS_DOMAIN ?? 'manacollections.ir';
|
||||||
|
|
||||||
|
if (!apiKey) throw new Error('MESHKEE_SMS_API_KEY is not set in .env');
|
||||||
|
if (!/^09\d{9}$/.test(to)) throw new Error('to must match 09xxxxxxxxx');
|
||||||
|
if (!message.trim()) throw new Error('message is empty');
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ domain, to, message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !body?.success) {
|
||||||
|
console.error('SMS send failed', { status: response.status, body });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SMS sent', { to, serverId: body.serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { BlogsModule } from './blogs/blogs.module';
|
||||||
|
import { BrandsModule } from './brands/brands.module';
|
||||||
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
|
import { CustomersModule } from './customers/customers.module';
|
||||||
|
import { MediaModule } from './media/media.module';
|
||||||
|
import { OrdersModule } from './orders/orders.module';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { ProductsModule } from './products/products.module';
|
||||||
|
import { ProfileModule } from './profile/profile.module';
|
||||||
|
import { PublicModule } from './public/public.module';
|
||||||
|
import { SmsModule } from './sms/sms.module';
|
||||||
|
import { StorageModule } from './storage/storage.module';
|
||||||
|
import { StoresModule } from './stores/stores.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
PrismaModule,
|
||||||
|
StorageModule,
|
||||||
|
SmsModule,
|
||||||
|
AuthModule,
|
||||||
|
MediaModule,
|
||||||
|
PublicModule,
|
||||||
|
CatalogModule,
|
||||||
|
StoresModule,
|
||||||
|
ProductsModule,
|
||||||
|
CategoriesModule,
|
||||||
|
BrandsModule,
|
||||||
|
BlogsModule,
|
||||||
|
CustomersModule,
|
||||||
|
OrdersModule,
|
||||||
|
ProfileModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import type { AuthUser } from './auth.types';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
|
import { CellNumberDto } from './dto/cell-number.dto';
|
||||||
|
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||||
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||||
|
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly auth: AuthService) {}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() dto: LoginDto) {
|
||||||
|
return this.auth.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login/send-code')
|
||||||
|
loginSendCode(@Body() dto: CellNumberDto) {
|
||||||
|
return this.auth.loginSendCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login/verify')
|
||||||
|
loginVerify(@Body() dto: OtpVerifyDto) {
|
||||||
|
return this.auth.loginVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('forgot-password/send-code')
|
||||||
|
forgotPasswordSendCode(@Body() dto: CellNumberDto) {
|
||||||
|
return this.auth.forgotPasswordSendCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('forgot-password/verify')
|
||||||
|
forgotPasswordVerify(@Body() dto: OtpVerifyDto) {
|
||||||
|
return this.auth.forgotPasswordVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('forgot-password/reset')
|
||||||
|
forgotPasswordReset(@Body() dto: ForgotPasswordResetDto) {
|
||||||
|
return this.auth.forgotPasswordReset(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register/send-code')
|
||||||
|
registerSendCode(@Body() dto: RegisterSendCodeDto) {
|
||||||
|
return this.auth.registerSendCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register/verify')
|
||||||
|
registerVerify(@Body() dto: RegisterVerifyDto) {
|
||||||
|
return this.auth.registerVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
refresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.auth.refresh(dto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
logout(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.auth.logout(dto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
me(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.auth.me(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { SmsModule } from '../sms/sms.module';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
SmsModule,
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m') as
|
||||||
|
| `${number}s`
|
||||||
|
| `${number}m`
|
||||||
|
| `${number}h`
|
||||||
|
| `${number}d`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { SmsOtp, SmsOtpPurpose, UserRole } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { createHash, randomBytes, randomInt } from 'crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SmsService } from '../sms/sms.service';
|
||||||
|
import { AuthUser, displayName } from './auth.types';
|
||||||
|
import { CellNumberDto } from './dto/cell-number.dto';
|
||||||
|
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||||
|
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||||
|
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||||
|
|
||||||
|
const OTP_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const OTP_RESEND_COOLDOWN_MS = 60 * 1000;
|
||||||
|
const OTP_MAX_ATTEMPTS = 5;
|
||||||
|
const OTP_LENGTH = 6;
|
||||||
|
|
||||||
|
type RegisterOtpPayload = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OtpSendResult = {
|
||||||
|
ok: true;
|
||||||
|
expiresInSeconds: number;
|
||||||
|
resendAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
include: { ownedStore: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||||
|
const user = await this.requireActiveUser(dto.cellNumber);
|
||||||
|
return this.createAndSendOtp({
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.login,
|
||||||
|
message: (code) => `کد ورود مانا کالکشن: ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginVerify(dto: OtpVerifyDto) {
|
||||||
|
await this.requireActiveUser(dto.cellNumber);
|
||||||
|
await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.login,
|
||||||
|
code: dto.code,
|
||||||
|
consume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
include: { ownedStore: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPasswordSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||||
|
const user = await this.requireActiveUser(dto.cellNumber);
|
||||||
|
return this.createAndSendOtp({
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.resetPassword,
|
||||||
|
message: (code) => `کد بازیابی رمز مانا کالکشن: ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPasswordVerify(dto: OtpVerifyDto) {
|
||||||
|
await this.requireActiveUser(dto.cellNumber);
|
||||||
|
await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.resetPassword,
|
||||||
|
code: dto.code,
|
||||||
|
consume: false,
|
||||||
|
});
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPasswordReset(dto: ForgotPasswordResetDto) {
|
||||||
|
await this.requireActiveUser(dto.cellNumber);
|
||||||
|
await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.resetPassword,
|
||||||
|
code: dto.code,
|
||||||
|
consume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
data: { passwordHash },
|
||||||
|
include: { ownedStore: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.refreshToken.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerSendCode(dto: RegisterSendCodeDto): Promise<OtpSendResult> {
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
const payload: RegisterOtpPayload = {
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
passwordHash,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createAndSendOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.register,
|
||||||
|
payload,
|
||||||
|
message: (code) => `کد تأیید مانا کالکشن: ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerVerify(dto: RegisterVerifyDto) {
|
||||||
|
const otp = await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.register,
|
||||||
|
code: dto.code,
|
||||||
|
consume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = otp.payload as RegisterOtpPayload | null;
|
||||||
|
if (
|
||||||
|
!payload?.firstName ||
|
||||||
|
!payload?.lastName ||
|
||||||
|
!payload?.passwordHash
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'اطلاعات ثبتنام نامعتبر است. دوباره شروع کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
firstName: payload.firstName,
|
||||||
|
lastName: payload.lastName,
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
passwordHash: payload.passwordHash,
|
||||||
|
role: UserRole.CUSTOMER,
|
||||||
|
},
|
||||||
|
include: { ownedStore: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
const tokenHash = this.hashToken(refreshToken);
|
||||||
|
const stored = await this.prisma.refreshToken.findFirst({
|
||||||
|
where: { tokenHash },
|
||||||
|
include: {
|
||||||
|
user: { include: { ownedStore: { select: { id: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stored || stored.expiresAt < new Date()) {
|
||||||
|
if (stored) {
|
||||||
|
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('توکن منقضی شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = stored.user;
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new UnauthorizedException('نشست نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(refreshToken?: string) {
|
||||||
|
if (!refreshToken) return { ok: true };
|
||||||
|
const tokenHash = this.hashToken(refreshToken);
|
||||||
|
await this.prisma.refreshToken.deleteMany({ where: { tokenHash } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(actor: AuthUser) {
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: actor.id },
|
||||||
|
include: {
|
||||||
|
ownedStore: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
nameFa: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
email: user.email,
|
||||||
|
instagram: user.instagram,
|
||||||
|
about: user.about,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
name: displayName(user),
|
||||||
|
store: user.ownedStore
|
||||||
|
? {
|
||||||
|
id: user.ownedStore.id,
|
||||||
|
slug: user.ownedStore.slug,
|
||||||
|
nameFa: user.ownedStore.nameFa,
|
||||||
|
status: user.ownedStore.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expose OTP helpers for store registration flow. */
|
||||||
|
createAndSendOtpPublic(input: {
|
||||||
|
cellNumber: string;
|
||||||
|
purpose: SmsOtpPurpose;
|
||||||
|
payload?: unknown;
|
||||||
|
message: (code: string) => string;
|
||||||
|
}) {
|
||||||
|
return this.createAndSendOtp(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertValidOtpPublic(input: {
|
||||||
|
cellNumber: string;
|
||||||
|
purpose: SmsOtpPurpose;
|
||||||
|
code: string;
|
||||||
|
consume: boolean;
|
||||||
|
}) {
|
||||||
|
return this.assertValidOtp(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
issueTokensPublic(user: {
|
||||||
|
id: string;
|
||||||
|
cellNumber: string;
|
||||||
|
role: UserRole;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
ownedStore?: { id: string } | null;
|
||||||
|
}) {
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireActiveUser(cellNumber: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { cellNumber } });
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('کاربری با این شماره یافت نشد');
|
||||||
|
}
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createAndSendOtp(input: {
|
||||||
|
cellNumber: string;
|
||||||
|
purpose: SmsOtpPurpose;
|
||||||
|
payload?: unknown;
|
||||||
|
message: (code: string) => string;
|
||||||
|
}): Promise<OtpSendResult> {
|
||||||
|
const latest = await this.prisma.smsOtp.findFirst({
|
||||||
|
where: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
latest &&
|
||||||
|
Date.now() - latest.createdAt.getTime() < OTP_RESEND_COOLDOWN_MS
|
||||||
|
) {
|
||||||
|
const waitSec = Math.ceil(
|
||||||
|
(OTP_RESEND_COOLDOWN_MS - (Date.now() - latest.createdAt.getTime())) /
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
throw new BadRequestException(
|
||||||
|
`لطفاً ${waitSec} ثانیه دیگر دوباره تلاش کنید`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(randomInt(0, 10 ** OTP_LENGTH)).padStart(
|
||||||
|
OTP_LENGTH,
|
||||||
|
'0',
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.smsOtp.deleteMany({
|
||||||
|
where: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.smsOtp.create({
|
||||||
|
data: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
codeHash: this.hashToken(code),
|
||||||
|
payload: input.payload ?? undefined,
|
||||||
|
expiresAt: new Date(Date.now() + OTP_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sms.send({
|
||||||
|
to: input.cellNumber,
|
||||||
|
message: input.message(code),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
expiresInSeconds: Math.floor(OTP_TTL_MS / 1000),
|
||||||
|
resendAfterSeconds: Math.floor(OTP_RESEND_COOLDOWN_MS / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertValidOtp(input: {
|
||||||
|
cellNumber: string;
|
||||||
|
purpose: SmsOtpPurpose;
|
||||||
|
code: string;
|
||||||
|
consume: boolean;
|
||||||
|
}): Promise<SmsOtp> {
|
||||||
|
const otp = await this.prisma.smsOtp.findFirst({
|
||||||
|
where: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!otp || otp.expiresAt < new Date()) {
|
||||||
|
if (otp) {
|
||||||
|
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
'کد تأیید منقضی شده است. دوباره درخواست کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otp.attempts >= OTP_MAX_ATTEMPTS) {
|
||||||
|
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||||
|
throw new BadRequestException(
|
||||||
|
'تعداد تلاشها بیش از حد مجاز است. دوباره درخواست کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeHash = this.hashToken(input.code.trim());
|
||||||
|
if (codeHash !== otp.codeHash) {
|
||||||
|
await this.prisma.smsOtp.update({
|
||||||
|
where: { id: otp.id },
|
||||||
|
data: { attempts: { increment: 1 } },
|
||||||
|
});
|
||||||
|
throw new BadRequestException('کد تأیید نادرست است');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.consume) {
|
||||||
|
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return otp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueTokens(user: {
|
||||||
|
id: string;
|
||||||
|
cellNumber: string;
|
||||||
|
role: UserRole;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
ownedStore?: { id: string } | null;
|
||||||
|
}) {
|
||||||
|
const storeId =
|
||||||
|
user.role === UserRole.STORE_ADMIN
|
||||||
|
? (user.ownedStore?.id ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const accessExpiresIn = this.config.get<string>(
|
||||||
|
'JWT_ACCESS_EXPIRES_IN',
|
||||||
|
'15m',
|
||||||
|
);
|
||||||
|
const accessToken = await this.jwt.signAsync(
|
||||||
|
{ sub: user.id, role: user.role, storeId },
|
||||||
|
{
|
||||||
|
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshToken = randomBytes(48).toString('hex');
|
||||||
|
const refreshDays = this.parseDurationDays(
|
||||||
|
this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
tokenHash: this.hashToken(refreshToken),
|
||||||
|
expiresAt: new Date(Date.now() + refreshDays * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
role: user.role,
|
||||||
|
storeId,
|
||||||
|
name: displayName(user),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashToken(token: string) {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDurationDays(value: string): number {
|
||||||
|
const match = /^(\d+)d$/i.exec(value.trim());
|
||||||
|
return match ? Number(match[1]) : 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
cellNumber: string;
|
||||||
|
role: UserRole;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
storeId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isStoreAdmin(role: UserRole): boolean {
|
||||||
|
return role === UserRole.STORE_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuperAdmin(role: UserRole): boolean {
|
||||||
|
return role === UserRole.SUPER_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayName(user: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}): string {
|
||||||
|
return `${user.firstName} ${user.lastName}`.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth.types';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class CellNumberDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsString, Length, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ForgotPasswordResetDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(4, 8)
|
||||||
|
@Matches(/^\d+$/, { message: 'کد تأیید باید عددی باشد' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||||
|
newPassword!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class OtpVerifyDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(4, 8)
|
||||||
|
@Matches(/^\d+$/, { message: 'کد تأیید باید عددی باشد' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterSendCodeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'نام الزامی است' })
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'نام خانوادگی الزامی است' })
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterVerifyDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(4, 8)
|
||||||
|
@Matches(/^\d+$/, { message: 'کد تأیید باید عددی باشد' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
import { AuthUser } from '../auth.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||||
|
ROLES_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredRoles?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<{ user?: AuthUser }>();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user || !requiredRoles.includes(user.role)) {
|
||||||
|
throw new ForbiddenException('دسترسی مجاز نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuthUser } from '../auth.types';
|
||||||
|
|
||||||
|
type JwtPayload = {
|
||||||
|
sub: string;
|
||||||
|
role: UserRole;
|
||||||
|
storeId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
include: { ownedStore: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.disabled) {
|
||||||
|
throw new UnauthorizedException('نشست نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
role: user.role,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
storeId: user.ownedStore?.id ?? payload.storeId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CreateBlogDto, UpdateBlogDto } from './dto/blog.dto';
|
||||||
|
import { BlogsService } from './blogs.service';
|
||||||
|
|
||||||
|
@Controller('blogs')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
export class BlogsController {
|
||||||
|
constructor(private readonly blogs: BlogsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('storeId') storeId?: string,
|
||||||
|
) {
|
||||||
|
return this.blogs.list(user, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.blogs.findOne(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreateBlogDto) {
|
||||||
|
return this.blogs.create(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateBlogDto,
|
||||||
|
) {
|
||||||
|
return this.blogs.update(user, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.blogs.remove(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { BlogsController } from './blogs.controller';
|
||||||
|
import { BlogsService } from './blogs.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BlogsController],
|
||||||
|
providers: [BlogsService, StoreScopeService],
|
||||||
|
exports: [BlogsService],
|
||||||
|
})
|
||||||
|
export class BlogsModule {}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { slugify } from '../common/slugify';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateBlogDto, UpdateBlogDto } from './dto/blog.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BlogsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storeScope: StoreScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(actor: AuthUser, storeIdQuery?: string) {
|
||||||
|
const storeId =
|
||||||
|
actor.role === UserRole.SUPER_ADMIN
|
||||||
|
? storeIdQuery
|
||||||
|
: (await this.storeScope.requireActiveStore(actor)).id;
|
||||||
|
|
||||||
|
return this.prisma.blogPost.findMany({
|
||||||
|
where: storeId ? { storeId } : undefined,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(actor: AuthUser, id: string) {
|
||||||
|
const post = await this.prisma.blogPost.findUnique({ where: { id } });
|
||||||
|
if (!post) {
|
||||||
|
throw new NotFoundException('مطلب یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, post.storeId);
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateBlogDto) {
|
||||||
|
const store = await this.storeScope.requireActiveStore(
|
||||||
|
actor,
|
||||||
|
dto.storeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.prisma.blogPost.create({
|
||||||
|
data: {
|
||||||
|
storeId: store.id,
|
||||||
|
title: dto.title,
|
||||||
|
summary: dto.summary ?? '',
|
||||||
|
body: dto.body ?? '',
|
||||||
|
author: dto.author ?? '',
|
||||||
|
imageUrl: dto.imageUrl,
|
||||||
|
imageKey: dto.imageKey,
|
||||||
|
published: dto.published ?? true,
|
||||||
|
metaTitle: dto.metaTitle,
|
||||||
|
metaDescription: dto.metaDescription,
|
||||||
|
slug: dto.slug ?? (slugify(dto.title) || undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(actor: AuthUser, id: string, dto: UpdateBlogDto) {
|
||||||
|
const existing = await this.prisma.blogPost.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('مطلب یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
|
||||||
|
return this.prisma.blogPost.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.title !== undefined ? { title: dto.title } : {}),
|
||||||
|
...(dto.summary !== undefined ? { summary: dto.summary } : {}),
|
||||||
|
...(dto.body !== undefined ? { body: dto.body } : {}),
|
||||||
|
...(dto.author !== undefined ? { author: dto.author } : {}),
|
||||||
|
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
|
||||||
|
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
|
||||||
|
...(dto.published !== undefined ? { published: dto.published } : {}),
|
||||||
|
...(dto.metaTitle !== undefined ? { metaTitle: dto.metaTitle } : {}),
|
||||||
|
...(dto.metaDescription !== undefined
|
||||||
|
? { metaDescription: dto.metaDescription }
|
||||||
|
: {}),
|
||||||
|
...(dto.slug !== undefined ? { slug: dto.slug } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(actor: AuthUser, id: string) {
|
||||||
|
const existing = await this.prisma.blogPost.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('مطلب یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
await this.prisma.blogPost.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreAccess(actor: AuthUser, storeId: string) {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) return;
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
if (store.id !== storeId) {
|
||||||
|
throw new NotFoundException('مطلب یافت نشد');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBlogDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
body?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
published?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
metaTitle?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
metaDescription?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBlogDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
body?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageKey?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
published?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
metaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
metaDescription?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
slug?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
|
||||||
|
import { BrandsService } from './brands.service';
|
||||||
|
|
||||||
|
@Controller('brands')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
export class BrandsController {
|
||||||
|
constructor(private readonly brands: BrandsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('storeId') storeId?: string,
|
||||||
|
) {
|
||||||
|
return this.brands.list(user, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreateBrandDto) {
|
||||||
|
return this.brands.create(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateBrandDto,
|
||||||
|
) {
|
||||||
|
return this.brands.update(user, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.brands.remove(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { BrandsController } from './brands.controller';
|
||||||
|
import { BrandsService } from './brands.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BrandsController],
|
||||||
|
providers: [BrandsService, StoreScopeService],
|
||||||
|
exports: [BrandsService],
|
||||||
|
})
|
||||||
|
export class BrandsModule {}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BrandsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storeScope: StoreScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(actor: AuthUser, storeIdQuery?: string) {
|
||||||
|
const storeId =
|
||||||
|
actor.role === UserRole.SUPER_ADMIN
|
||||||
|
? storeIdQuery
|
||||||
|
: (await this.storeScope.requireActiveStore(actor)).id;
|
||||||
|
|
||||||
|
return this.prisma.brand.findMany({
|
||||||
|
where: storeId ? { storeId } : undefined,
|
||||||
|
orderBy: { nameFa: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateBrandDto) {
|
||||||
|
const store = await this.storeScope.requireActiveStore(
|
||||||
|
actor,
|
||||||
|
dto.storeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.prisma.brand.create({
|
||||||
|
data: {
|
||||||
|
storeId: store.id,
|
||||||
|
nameFa: dto.nameFa,
|
||||||
|
nameEn: dto.nameEn ?? '',
|
||||||
|
imageUrl: dto.imageUrl,
|
||||||
|
imageKey: dto.imageKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(actor: AuthUser, id: string, dto: UpdateBrandDto) {
|
||||||
|
const existing = await this.prisma.brand.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('برند یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
|
||||||
|
return this.prisma.brand.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||||
|
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
|
||||||
|
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(actor: AuthUser, id: string) {
|
||||||
|
const existing = await this.prisma.brand.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('برند یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
await this.prisma.brand.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreAccess(actor: AuthUser, storeId: string) {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) return;
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
if (store.id !== storeId) {
|
||||||
|
throw new NotFoundException('برند یافت نشد');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBrandDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBrandDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageKey?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const colorCatalog = [
|
||||||
|
{ id: 'black', nameFa: 'مشکی', hex: '#0a0a0a' },
|
||||||
|
{ id: 'white', nameFa: 'سفید', hex: '#f5f5f5' },
|
||||||
|
{ id: 'cream', nameFa: 'کرم', hex: '#e8dcc8' },
|
||||||
|
{ id: 'beige', nameFa: 'بژ', hex: '#c4a882' },
|
||||||
|
{ id: 'navy', nameFa: 'سرمهای', hex: '#1b2a4a' },
|
||||||
|
{ id: 'olive', nameFa: 'زیتونی', hex: '#556b2f' },
|
||||||
|
{ id: 'burgundy', nameFa: 'زرشکی', hex: '#6b1e2a' },
|
||||||
|
{ id: 'gray', nameFa: 'طوسی', hex: '#8a8a8a' },
|
||||||
|
{ id: 'brown', nameFa: 'قهوهای', hex: '#6b4423' },
|
||||||
|
{ id: 'red', nameFa: 'قرمز', hex: '#b91c1c' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sizeCatalog = [
|
||||||
|
{ id: 'xs', nameFa: 'XS' },
|
||||||
|
{ id: 's', nameFa: 'S' },
|
||||||
|
{ id: 'm', nameFa: 'M' },
|
||||||
|
{ id: 'l', nameFa: 'L' },
|
||||||
|
{ id: 'xl', nameFa: 'XL' },
|
||||||
|
{ id: 'xxl', nameFa: 'XXL' },
|
||||||
|
{ id: '36', nameFa: '36' },
|
||||||
|
{ id: '38', nameFa: '38' },
|
||||||
|
{ id: '40', nameFa: '40' },
|
||||||
|
{ id: '42', nameFa: '42' },
|
||||||
|
{ id: '44', nameFa: '44' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const packageCatalog = [
|
||||||
|
{ id: '6', nameFa: '۶ تایی' },
|
||||||
|
{ id: '12', nameFa: '۱۲ تایی' },
|
||||||
|
];
|
||||||
|
|
||||||
|
@Controller('catalog')
|
||||||
|
export class CatalogController {
|
||||||
|
@Get()
|
||||||
|
getAll() {
|
||||||
|
return {
|
||||||
|
colors: colorCatalog,
|
||||||
|
sizes: sizeCatalog,
|
||||||
|
packages: packageCatalog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('colors')
|
||||||
|
colors() {
|
||||||
|
return colorCatalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sizes')
|
||||||
|
sizes() {
|
||||||
|
return sizeCatalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('packages')
|
||||||
|
packages() {
|
||||||
|
return packageCatalog;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CatalogController } from './catalog.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CatalogController],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CreateCategoryDto, UpdateCategoryDto } from './dto/category.dto';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Controller('categories')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categories: CategoriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('storeId') storeId?: string,
|
||||||
|
) {
|
||||||
|
return this.categories.list(user, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreateCategoryDto) {
|
||||||
|
return this.categories.create(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.categories.update(user, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.categories.remove(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [CategoriesService, StoreScopeService],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateCategoryDto, UpdateCategoryDto } from './dto/category.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storeScope: StoreScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(actor: AuthUser, storeIdQuery?: string) {
|
||||||
|
const storeId =
|
||||||
|
actor.role === UserRole.SUPER_ADMIN
|
||||||
|
? storeIdQuery
|
||||||
|
: (await this.storeScope.requireActiveStore(actor)).id;
|
||||||
|
|
||||||
|
const categories = await this.prisma.category.findMany({
|
||||||
|
where: storeId ? { storeId } : undefined,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { nameFa: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.buildTree(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateCategoryDto) {
|
||||||
|
const store = await this.storeScope.requireActiveStore(
|
||||||
|
actor,
|
||||||
|
dto.storeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.prisma.category.create({
|
||||||
|
data: {
|
||||||
|
storeId: store.id,
|
||||||
|
nameFa: dto.nameFa,
|
||||||
|
nameEn: dto.nameEn ?? '',
|
||||||
|
parentId: dto.parentId,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
colors: dto.colors ?? [],
|
||||||
|
sizes: dto.sizes ?? [],
|
||||||
|
packages: dto.packages ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(actor: AuthUser, id: string, dto: UpdateCategoryDto) {
|
||||||
|
const existing = await this.prisma.category.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
|
||||||
|
return this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||||
|
...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(dto.colors !== undefined ? { colors: dto.colors } : {}),
|
||||||
|
...(dto.sizes !== undefined ? { sizes: dto.sizes } : {}),
|
||||||
|
...(dto.packages !== undefined ? { packages: dto.packages } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(actor: AuthUser, id: string) {
|
||||||
|
const existing = await this.prisma.category.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
await this.prisma.category.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTree<
|
||||||
|
T extends { id: string; parentId: string | null },
|
||||||
|
>(items: T[]) {
|
||||||
|
const byParent = new Map<string | null, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.parentId;
|
||||||
|
const list = byParent.get(key) ?? [];
|
||||||
|
list.push(item);
|
||||||
|
byParent.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const walk = (parentId: string | null): Array<T & { children: unknown[] }> =>
|
||||||
|
(byParent.get(parentId) ?? []).map((node) => ({
|
||||||
|
...node,
|
||||||
|
children: walk(node.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return walk(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreAccess(actor: AuthUser, storeId: string) {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) return;
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
if (store.id !== storeId) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
colors?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
sizes?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
packages?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCategoryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
colors?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
sizes?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
packages?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Store, StoreStatus, UserRole } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoreScopeService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STORE_ADMIN must own an active store for product/catalog CRUD.
|
||||||
|
* SUPER_ADMIN may pass an explicit storeId.
|
||||||
|
*/
|
||||||
|
async requireActiveStore(
|
||||||
|
actor: AuthUser,
|
||||||
|
storeId?: string,
|
||||||
|
): Promise<Store> {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) {
|
||||||
|
if (!storeId) {
|
||||||
|
throw new ForbiddenException('شناسه فروشگاه الزامی است');
|
||||||
|
}
|
||||||
|
const store = await this.prisma.store.findUnique({
|
||||||
|
where: { id: storeId },
|
||||||
|
});
|
||||||
|
if (!store) {
|
||||||
|
throw new NotFoundException('فروشگاه یافت نشد');
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.role !== UserRole.STORE_ADMIN) {
|
||||||
|
throw new ForbiddenException('دسترسی مجاز نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await this.prisma.store.findUnique({
|
||||||
|
where: { ownerId: actor.id },
|
||||||
|
});
|
||||||
|
if (!store) {
|
||||||
|
throw new ForbiddenException('فروشگاهی به این حساب متصل نیست');
|
||||||
|
}
|
||||||
|
if (store.status !== StoreStatus.active) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'فروشگاه هنوز فعال نشده است. فقط پروفایل فروشگاه قابل ویرایش است',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own store for profile read/update — any status except missing. */
|
||||||
|
async requireOwnedStore(actor: AuthUser): Promise<Store> {
|
||||||
|
if (actor.role !== UserRole.STORE_ADMIN) {
|
||||||
|
throw new ForbiddenException('دسترسی مجاز نیست');
|
||||||
|
}
|
||||||
|
const store = await this.prisma.store.findUnique({
|
||||||
|
where: { ownerId: actor.id },
|
||||||
|
});
|
||||||
|
if (!store) {
|
||||||
|
throw new NotFoundException('فروشگاه یافت نشد');
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveStoreIdFilter(
|
||||||
|
actor: AuthUser,
|
||||||
|
queryStoreId?: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) {
|
||||||
|
return queryStoreId;
|
||||||
|
}
|
||||||
|
return actor.storeId ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
|
||||||
|
@Controller('customers')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
export class CustomersController {
|
||||||
|
constructor(private readonly customers: CustomersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.customers.list(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { CustomersController } from './customers.controller';
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomersController],
|
||||||
|
providers: [CustomersService, StoreScopeService],
|
||||||
|
})
|
||||||
|
export class CustomersModule {}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storeScope: StoreScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(actor: AuthUser) {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) {
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
where: { role: UserRole.CUSTOMER },
|
||||||
|
select: this.customerSelect(),
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
const items = await this.prisma.orderItem.findMany({
|
||||||
|
where: { storeId: store.id, order: { customerId: { not: null } } },
|
||||||
|
select: {
|
||||||
|
order: {
|
||||||
|
select: {
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
cellNumber: true,
|
||||||
|
email: true,
|
||||||
|
instagram: true,
|
||||||
|
about: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
disabled: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const byId = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
cellNumber: string;
|
||||||
|
email: string | null;
|
||||||
|
instagram: string | null;
|
||||||
|
about: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const customer = item.order.customer;
|
||||||
|
if (customer && !byId.has(customer.id)) {
|
||||||
|
byId.set(customer.id, {
|
||||||
|
...customer,
|
||||||
|
name: `${customer.firstName} ${customer.lastName}`.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private customerSelect() {
|
||||||
|
return {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
cellNumber: true,
|
||||||
|
email: true,
|
||||||
|
instagram: true,
|
||||||
|
about: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
disabled: true,
|
||||||
|
createdAt: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGIN?.split(',').map((s) => s.trim()) ?? true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 3002);
|
||||||
|
await app.listen(port);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Mana Collection API listening on http://localhost:${port}/api/v1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
export const MEDIA_KINDS = [
|
||||||
|
'main',
|
||||||
|
'gallery',
|
||||||
|
'blog',
|
||||||
|
'brand',
|
||||||
|
'store',
|
||||||
|
'avatar',
|
||||||
|
'temp',
|
||||||
|
] as const;
|
||||||
|
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||||
|
|
||||||
|
export class MediaUploadQueryDto {
|
||||||
|
@IsIn(MEDIA_KINDS, { message: 'نوع فایل نامعتبر است' })
|
||||||
|
kind!: MediaKind;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { MediaUploadQueryDto } from './dto/media-upload-query.dto';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
|
@Controller('media')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
export class MediaController {
|
||||||
|
constructor(private readonly media: MediaService) {}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
upload(
|
||||||
|
@Query() query: MediaUploadQueryDto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.media.upload(query.kind, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { MediaController } from './media.controller';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, StorageModule],
|
||||||
|
controllers: [MediaController],
|
||||||
|
providers: [MediaService],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
PayloadTooLargeException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { extname } from 'path';
|
||||||
|
import {
|
||||||
|
blogMediaKey,
|
||||||
|
brandMediaKey,
|
||||||
|
productGalleryKey,
|
||||||
|
productMainKey,
|
||||||
|
storeMediaKey,
|
||||||
|
tempMediaKey,
|
||||||
|
userMediaKey,
|
||||||
|
} from '../storage/storage-keys';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
import { MediaKind } from './dto/media-upload-query.dto';
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||||
|
|
||||||
|
const MIME_EXTENSIONS: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
constructor(
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upload(kind: MediaKind, file: Express.Multer.File) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('فایل ارسال نشده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||||
|
throw new BadRequestException('فرمت تصویر مجاز نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMb = Number(
|
||||||
|
this.config.get<string>('MEDIA_MAX_FILE_SIZE_MB', '10'),
|
||||||
|
);
|
||||||
|
const maxBytes = maxMb * 1024 * 1024;
|
||||||
|
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
throw new PayloadTooLargeException(
|
||||||
|
`حجم فایل نباید بیشتر از ${maxMb} مگابایت باشد`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext =
|
||||||
|
MIME_EXTENSIONS[file.mimetype] ?? extname(file.originalname) ?? '';
|
||||||
|
const fileName = `${randomUUID()}${ext}`;
|
||||||
|
const key = this.resolveKey(kind, fileName);
|
||||||
|
|
||||||
|
const stored = await this.storage.upload({
|
||||||
|
key,
|
||||||
|
body: file.buffer,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: stored.publicUrl,
|
||||||
|
storageKey: stored.storagePath,
|
||||||
|
storageDisk: stored.storageDisk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveKey(kind: MediaKind, fileName: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'main':
|
||||||
|
return productMainKey(fileName);
|
||||||
|
case 'gallery':
|
||||||
|
return productGalleryKey(fileName);
|
||||||
|
case 'blog':
|
||||||
|
return blogMediaKey(fileName);
|
||||||
|
case 'brand':
|
||||||
|
return brandMediaKey(fileName);
|
||||||
|
case 'store':
|
||||||
|
return storeMediaKey(fileName);
|
||||||
|
case 'avatar':
|
||||||
|
return userMediaKey(fileName);
|
||||||
|
case 'temp':
|
||||||
|
return tempMediaKey(fileName);
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('نوع فایل نامعتبر است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateOrderStoreStatusDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn([
|
||||||
|
'pending',
|
||||||
|
'confirmed',
|
||||||
|
'preparing',
|
||||||
|
'shipped',
|
||||||
|
'delivered',
|
||||||
|
'cancelled',
|
||||||
|
])
|
||||||
|
status!:
|
||||||
|
| 'pending'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'preparing'
|
||||||
|
| 'shipped'
|
||||||
|
| 'delivered'
|
||||||
|
| 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateOrderStatusDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn([
|
||||||
|
'pending',
|
||||||
|
'confirmed',
|
||||||
|
'preparing',
|
||||||
|
'shipped',
|
||||||
|
'delivered',
|
||||||
|
'cancelled',
|
||||||
|
])
|
||||||
|
status!:
|
||||||
|
| 'pending'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'preparing'
|
||||||
|
| 'shipped'
|
||||||
|
| 'delivered'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import {
|
||||||
|
UpdateOrderStatusDto,
|
||||||
|
UpdateOrderStoreStatusDto,
|
||||||
|
} from './dto/order.dto';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
@Controller('orders')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
export class OrdersController {
|
||||||
|
constructor(private readonly orders: OrdersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.orders.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.orders.findOne(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/fulfillment')
|
||||||
|
@Roles(UserRole.STORE_ADMIN)
|
||||||
|
updateFulfillment(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateOrderStoreStatusDto,
|
||||||
|
) {
|
||||||
|
return this.orders.updateStoreFulfillment(user, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@Roles(UserRole.SUPER_ADMIN)
|
||||||
|
updateStatus(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateOrderStatusDto,
|
||||||
|
) {
|
||||||
|
return this.orders.updateStatus(user, id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { OrdersController } from './orders.controller';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OrdersController],
|
||||||
|
providers: [OrdersService, StoreScopeService],
|
||||||
|
})
|
||||||
|
export class OrdersModule {}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OrderStoreStatus, OrderStatus, UserRole } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
UpdateOrderStatusDto,
|
||||||
|
UpdateOrderStoreStatusDto,
|
||||||
|
} from './dto/order.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrdersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storeScope: StoreScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(actor: AuthUser) {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) {
|
||||||
|
return this.prisma.order.findMany({
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
fulfillments: true,
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
cellNumber: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
return this.prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
items: { some: { storeId: store.id } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: { where: { storeId: store.id } },
|
||||||
|
fulfillments: { where: { storeId: store.id } },
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
cellNumber: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(actor: AuthUser, id: string) {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) {
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { items: true, fulfillments: true, customer: true },
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
const order = await this.prisma.order.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
items: { some: { storeId: store.id } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: { where: { storeId: store.id } },
|
||||||
|
fulfillments: { where: { storeId: store.id } },
|
||||||
|
customer: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStoreFulfillment(
|
||||||
|
actor: AuthUser,
|
||||||
|
orderId: string,
|
||||||
|
dto: UpdateOrderStoreStatusDto,
|
||||||
|
) {
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
const fulfillment = await this.prisma.orderStoreFulfillment.findUnique({
|
||||||
|
where: {
|
||||||
|
orderId_storeId: { orderId, storeId: store.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!fulfillment) {
|
||||||
|
throw new NotFoundException('وضعیت سفارش فروشگاه یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.orderStoreFulfillment.update({
|
||||||
|
where: { id: fulfillment.id },
|
||||||
|
data: { status: dto.status as OrderStoreStatus },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
actor: AuthUser,
|
||||||
|
orderId: string,
|
||||||
|
dto: UpdateOrderStatusDto,
|
||||||
|
) {
|
||||||
|
if (actor.role !== UserRole.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('فقط سوپر ادمین میتواند وضعیت کلی را تغییر دهد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||||
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
|
||||||
|
return this.prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
status: dto.status as OrderStatus,
|
||||||
|
...(dto.note !== undefined ? { note: dto.note } : {}),
|
||||||
|
},
|
||||||
|
include: { items: true, fulfillments: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ProductVariantDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
colorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sizeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
packageId?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
discountPrice?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateProductDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
discountPrice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
brandId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
galleryUrls?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
galleryKeys?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
published?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProductVariantDto)
|
||||||
|
variants?: ProductVariantDto[];
|
||||||
|
|
||||||
|
/** SUPER_ADMIN only */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
storeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProductDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
price?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
discountPrice?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
brandId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageKey?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
galleryUrls?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
galleryKeys?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
published?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ProductVariantDto)
|
||||||
|
variants?: ProductVariantDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/create-product.dto';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
|
@Controller('products')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
export class ProductsController {
|
||||||
|
constructor(private readonly products: ProductsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('storeId') storeId?: string,
|
||||||
|
@Query('name') name?: string,
|
||||||
|
@Query('categoryId') categoryId?: string,
|
||||||
|
@Query('minPrice') minPrice?: number,
|
||||||
|
@Query('maxPrice') maxPrice?: number,
|
||||||
|
) {
|
||||||
|
return this.products.list(user, {
|
||||||
|
storeId,
|
||||||
|
name,
|
||||||
|
categoryId,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.products.findOne(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreateProductDto) {
|
||||||
|
return this.products.create(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateProductDto,
|
||||||
|
) {
|
||||||
|
return this.products.update(user, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.products.remove(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { ProductsController } from './products.controller';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProductsController],
|
||||||
|
providers: [ProductsService, StoreScopeService],
|
||||||
|
exports: [ProductsService],
|
||||||
|
})
|
||||||
|
export class ProductsModule {}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma, UserRole } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/create-product.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storeScope: StoreScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
actor: AuthUser,
|
||||||
|
query: {
|
||||||
|
storeId?: string;
|
||||||
|
name?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const storeId =
|
||||||
|
actor.role === UserRole.SUPER_ADMIN
|
||||||
|
? query.storeId
|
||||||
|
: (await this.storeScope.requireActiveStore(actor)).id;
|
||||||
|
|
||||||
|
const where: Prisma.ProductWhereInput = {
|
||||||
|
...(storeId ? { storeId } : {}),
|
||||||
|
...(query.categoryId ? { categoryId: query.categoryId } : {}),
|
||||||
|
...(query.name
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nameFa: { contains: query.name, mode: 'insensitive' } },
|
||||||
|
{ nameEn: { contains: query.name, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
||||||
|
? {
|
||||||
|
price: {
|
||||||
|
...(query.minPrice !== undefined ? { gte: query.minPrice } : {}),
|
||||||
|
...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.prisma.product.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
variants: true,
|
||||||
|
category: { select: { id: true, nameFa: true } },
|
||||||
|
brand: { select: { id: true, nameFa: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(actor: AuthUser, id: string) {
|
||||||
|
const product = await this.prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { variants: true, category: true, brand: true },
|
||||||
|
});
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, product.storeId);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateProductDto) {
|
||||||
|
const store = await this.storeScope.requireActiveStore(
|
||||||
|
actor,
|
||||||
|
dto.storeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.prisma.product.create({
|
||||||
|
data: {
|
||||||
|
storeId: store.id,
|
||||||
|
nameFa: dto.nameFa,
|
||||||
|
nameEn: dto.nameEn ?? '',
|
||||||
|
summary: dto.summary ?? '',
|
||||||
|
description: dto.description ?? '',
|
||||||
|
price: dto.price,
|
||||||
|
discountPrice: dto.discountPrice,
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
brandId: dto.brandId,
|
||||||
|
imageUrl: dto.imageUrl,
|
||||||
|
imageKey: dto.imageKey,
|
||||||
|
galleryUrls: dto.galleryUrls ?? [],
|
||||||
|
galleryKeys: dto.galleryKeys ?? [],
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
published: dto.published ?? true,
|
||||||
|
variants: dto.variants?.length
|
||||||
|
? {
|
||||||
|
create: dto.variants.map((v) => ({
|
||||||
|
colorId: v.colorId ?? '',
|
||||||
|
sizeId: v.sizeId ?? '',
|
||||||
|
packageId: v.packageId ?? '',
|
||||||
|
price: v.price,
|
||||||
|
discountPrice: v.discountPrice,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: { variants: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(actor: AuthUser, id: string, dto: UpdateProductDto) {
|
||||||
|
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
if (dto.variants) {
|
||||||
|
await tx.productVariant.deleteMany({ where: { productId: id } });
|
||||||
|
if (dto.variants.length) {
|
||||||
|
await tx.productVariant.createMany({
|
||||||
|
data: dto.variants.map((v) => ({
|
||||||
|
productId: id,
|
||||||
|
colorId: v.colorId ?? '',
|
||||||
|
sizeId: v.sizeId ?? '',
|
||||||
|
packageId: v.packageId ?? '',
|
||||||
|
price: v.price,
|
||||||
|
discountPrice: v.discountPrice,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||||
|
...(dto.summary !== undefined ? { summary: dto.summary } : {}),
|
||||||
|
...(dto.description !== undefined
|
||||||
|
? { description: dto.description }
|
||||||
|
: {}),
|
||||||
|
...(dto.price !== undefined ? { price: dto.price } : {}),
|
||||||
|
...(dto.discountPrice !== undefined
|
||||||
|
? { discountPrice: dto.discountPrice }
|
||||||
|
: {}),
|
||||||
|
...(dto.categoryId !== undefined
|
||||||
|
? { categoryId: dto.categoryId }
|
||||||
|
: {}),
|
||||||
|
...(dto.brandId !== undefined ? { brandId: dto.brandId } : {}),
|
||||||
|
...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}),
|
||||||
|
...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}),
|
||||||
|
...(dto.galleryUrls !== undefined
|
||||||
|
? { galleryUrls: dto.galleryUrls }
|
||||||
|
: {}),
|
||||||
|
...(dto.galleryKeys !== undefined
|
||||||
|
? { galleryKeys: dto.galleryKeys }
|
||||||
|
: {}),
|
||||||
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
...(dto.published !== undefined ? { published: dto.published } : {}),
|
||||||
|
},
|
||||||
|
include: { variants: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(actor: AuthUser, id: string) {
|
||||||
|
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
await this.assertStoreAccess(actor, existing.storeId);
|
||||||
|
await this.prisma.product.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreAccess(actor: AuthUser, storeId: string) {
|
||||||
|
if (actor.role === UserRole.SUPER_ADMIN) return;
|
||||||
|
const store = await this.storeScope.requireActiveStore(actor);
|
||||||
|
if (store.id !== storeId) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
email?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
instagram?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
about?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
avatarKey?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateAddressDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
province!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
city!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
fullAddress!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
landline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateAddressDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
province?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
fullAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
landline?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import {
|
||||||
|
CreateAddressDto,
|
||||||
|
UpdateAddressDto,
|
||||||
|
UpdateProfileDto,
|
||||||
|
} from './dto/profile.dto';
|
||||||
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
|
@Controller('profile')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ProfileController {
|
||||||
|
constructor(private readonly profile: ProfileService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.profile.getProfile(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
update(@CurrentUser() user: AuthUser, @Body() dto: UpdateProfileDto) {
|
||||||
|
return this.profile.updateProfile(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('addresses')
|
||||||
|
listAddresses(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.profile.listAddresses(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('addresses')
|
||||||
|
createAddress(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateAddressDto,
|
||||||
|
) {
|
||||||
|
return this.profile.createAddress(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('addresses/:id')
|
||||||
|
updateAddress(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateAddressDto,
|
||||||
|
) {
|
||||||
|
return this.profile.updateAddress(user, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('addresses/:id')
|
||||||
|
removeAddress(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.profile.removeAddress(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProfileController } from './profile.controller';
|
||||||
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProfileController],
|
||||||
|
providers: [ProfileService],
|
||||||
|
})
|
||||||
|
export class ProfileModule {}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AuthUser, displayName } from '../auth/auth.types';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CreateAddressDto,
|
||||||
|
UpdateAddressDto,
|
||||||
|
UpdateProfileDto,
|
||||||
|
} from './dto/profile.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProfileService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getProfile(actor: AuthUser) {
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: actor.id },
|
||||||
|
include: { addresses: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
email: user.email,
|
||||||
|
instagram: user.instagram,
|
||||||
|
about: user.about,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
avatarKey: user.avatarKey,
|
||||||
|
role: user.role,
|
||||||
|
name: displayName(user),
|
||||||
|
addresses: user.addresses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(actor: AuthUser, dto: UpdateProfileDto) {
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id: actor.id },
|
||||||
|
data: {
|
||||||
|
...(dto.firstName !== undefined
|
||||||
|
? { firstName: dto.firstName.trim() }
|
||||||
|
: {}),
|
||||||
|
...(dto.lastName !== undefined
|
||||||
|
? { lastName: dto.lastName.trim() }
|
||||||
|
: {}),
|
||||||
|
...(dto.email !== undefined ? { email: dto.email } : {}),
|
||||||
|
...(dto.instagram !== undefined ? { instagram: dto.instagram } : {}),
|
||||||
|
...(dto.about !== undefined ? { about: dto.about } : {}),
|
||||||
|
...(dto.avatarUrl !== undefined ? { avatarUrl: dto.avatarUrl } : {}),
|
||||||
|
...(dto.avatarKey !== undefined ? { avatarKey: dto.avatarKey } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
email: user.email,
|
||||||
|
instagram: user.instagram,
|
||||||
|
about: user.about,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
name: displayName(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listAddresses(actor: AuthUser) {
|
||||||
|
return this.prisma.userAddress.findMany({
|
||||||
|
where: { userId: actor.id },
|
||||||
|
orderBy: { label: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createAddress(actor: AuthUser, dto: CreateAddressDto) {
|
||||||
|
return this.prisma.userAddress.create({
|
||||||
|
data: {
|
||||||
|
userId: actor.id,
|
||||||
|
label: dto.label ?? '',
|
||||||
|
province: dto.province,
|
||||||
|
city: dto.city,
|
||||||
|
fullAddress: dto.fullAddress,
|
||||||
|
landline: dto.landline,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAddress(
|
||||||
|
actor: AuthUser,
|
||||||
|
id: string,
|
||||||
|
dto: UpdateAddressDto,
|
||||||
|
) {
|
||||||
|
await this.requireOwnedAddress(actor.id, id);
|
||||||
|
return this.prisma.userAddress.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.label !== undefined ? { label: dto.label } : {}),
|
||||||
|
...(dto.province !== undefined ? { province: dto.province } : {}),
|
||||||
|
...(dto.city !== undefined ? { city: dto.city } : {}),
|
||||||
|
...(dto.fullAddress !== undefined
|
||||||
|
? { fullAddress: dto.fullAddress }
|
||||||
|
: {}),
|
||||||
|
...(dto.landline !== undefined ? { landline: dto.landline } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAddress(actor: AuthUser, id: string) {
|
||||||
|
await this.requireOwnedAddress(actor.id, id);
|
||||||
|
await this.prisma.userAddress.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireOwnedAddress(userId: string, id: string) {
|
||||||
|
const address = await this.prisma.userAddress.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
if (!address) {
|
||||||
|
throw new NotFoundException('آدرس یافت نشد');
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
|
import { PublicService } from './public.service';
|
||||||
|
|
||||||
|
@Controller('public')
|
||||||
|
export class PublicController {
|
||||||
|
constructor(private readonly publicService: PublicService) {}
|
||||||
|
|
||||||
|
@Get('stores')
|
||||||
|
listStores() {
|
||||||
|
return this.publicService.listStores();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('products')
|
||||||
|
listProducts(
|
||||||
|
@Query('storeId') storeId?: string,
|
||||||
|
@Query('storeSlug') storeSlug?: string,
|
||||||
|
@Query('name') name?: string,
|
||||||
|
@Query('categoryId') categoryId?: string,
|
||||||
|
@Query('minPrice') minPrice?: number,
|
||||||
|
@Query('maxPrice') maxPrice?: number,
|
||||||
|
) {
|
||||||
|
return this.publicService.listProducts({
|
||||||
|
storeId,
|
||||||
|
storeSlug,
|
||||||
|
name,
|
||||||
|
categoryId,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('products/:id')
|
||||||
|
findProduct(@Param('id') id: string) {
|
||||||
|
return this.publicService.findProduct(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('blogs')
|
||||||
|
listBlogs(@Query('storeSlug') storeSlug?: string) {
|
||||||
|
return this.publicService.listBlogs(storeSlug);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PublicController } from './public.controller';
|
||||||
|
import { PublicService } from './public.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PublicController],
|
||||||
|
providers: [PublicService],
|
||||||
|
})
|
||||||
|
export class PublicModule {}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma, StoreStatus } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PublicService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
listStores() {
|
||||||
|
return this.prisma.store.findMany({
|
||||||
|
where: { status: StoreStatus.active },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
nameFa: true,
|
||||||
|
nameEn: true,
|
||||||
|
description: true,
|
||||||
|
logoUrl: true,
|
||||||
|
},
|
||||||
|
orderBy: { nameFa: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listProducts(query: {
|
||||||
|
storeId?: string;
|
||||||
|
storeSlug?: string;
|
||||||
|
name?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
minPrice?: number;
|
||||||
|
maxPrice?: number;
|
||||||
|
}) {
|
||||||
|
const where: Prisma.ProductWhereInput = {
|
||||||
|
published: true,
|
||||||
|
store: { status: StoreStatus.active },
|
||||||
|
...(query.storeId ? { storeId: query.storeId } : {}),
|
||||||
|
...(query.storeSlug ? { store: { slug: query.storeSlug, status: StoreStatus.active } } : {}),
|
||||||
|
...(query.categoryId ? { categoryId: query.categoryId } : {}),
|
||||||
|
...(query.name
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nameFa: { contains: query.name, mode: 'insensitive' } },
|
||||||
|
{ nameEn: { contains: query.name, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
||||||
|
? {
|
||||||
|
price: {
|
||||||
|
...(query.minPrice !== undefined ? { gte: query.minPrice } : {}),
|
||||||
|
...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.prisma.product.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
variants: true,
|
||||||
|
store: {
|
||||||
|
select: { id: true, slug: true, nameFa: true, logoUrl: true },
|
||||||
|
},
|
||||||
|
category: { select: { id: true, nameFa: true } },
|
||||||
|
brand: { select: { id: true, nameFa: true, imageUrl: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findProduct(id: string) {
|
||||||
|
const product = await this.prisma.product.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
published: true,
|
||||||
|
store: { status: StoreStatus.active },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
variants: true,
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
nameFa: true,
|
||||||
|
nameEn: true,
|
||||||
|
logoUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: true,
|
||||||
|
brand: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
listBlogs(storeSlug?: string) {
|
||||||
|
return this.prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
published: true,
|
||||||
|
store: {
|
||||||
|
status: StoreStatus.active,
|
||||||
|
...(storeSlug ? { slug: storeSlug } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: { select: { id: true, slug: true, nameFa: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SmsService],
|
||||||
|
exports: [SmsService],
|
||||||
|
})
|
||||||
|
export class SmsModule {}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SendSmsInput, SendSmsResult } from './sms.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsService {
|
||||||
|
private readonly logger = new Logger(SmsService.name);
|
||||||
|
private readonly apiUrl: string;
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly domain: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
this.apiUrl = this.config.getOrThrow<string>('MESHKEE_SMS_URL');
|
||||||
|
this.apiKey = this.config.getOrThrow<string>('MESHKEE_SMS_API_KEY');
|
||||||
|
this.domain = this.config.getOrThrow<string>('MESHKEE_SMS_DOMAIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyMobiles(): string[] {
|
||||||
|
const raw = this.config.get<string>('SMS_NOTIFY_MOBILES', '');
|
||||||
|
return raw
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => /^09\d{9}$/.test(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendQuietly(input: SendSmsInput): Promise<SendSmsResult | null> {
|
||||||
|
try {
|
||||||
|
return await this.send(input);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Meshkee SMS skipped for ${input.to}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyAdmins(message: string, except?: string) {
|
||||||
|
const targets = this.notifyMobiles().filter((to) => to !== except);
|
||||||
|
await Promise.all(
|
||||||
|
targets.map((to) => this.sendQuietly({ to, message })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(input: SendSmsInput): Promise<SendSmsResult> {
|
||||||
|
const to = input.to.trim();
|
||||||
|
const message = input.message.trim();
|
||||||
|
|
||||||
|
if (!/^09\d{9}$/.test(to)) {
|
||||||
|
throw new InternalServerErrorException('Invalid SMS destination');
|
||||||
|
}
|
||||||
|
if (!message) {
|
||||||
|
throw new InternalServerErrorException('SMS message is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(this.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': this.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: this.domain,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Meshkee SMS request failed', err);
|
||||||
|
throw new ServiceUnavailableException('SMS provider unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| { success?: boolean; serverId?: string; message?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (!response.ok || !body?.success || !body.serverId) {
|
||||||
|
this.logger.error(
|
||||||
|
`Meshkee SMS rejected: status=${response.status} body=${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('SMS send failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, serverId: body.serverId };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type SendSmsInput = {
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SendSmsResult = {
|
||||||
|
success: true;
|
||||||
|
serverId: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { StoredObject, UploadObjectInput } from './storage.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class S3StorageDriver {
|
||||||
|
private readonly client: S3Client;
|
||||||
|
private readonly bucket: string;
|
||||||
|
private readonly publicUrlBase: string;
|
||||||
|
private readonly storageDisk = 's3';
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
const endpoint = this.config.getOrThrow<string>('S3_ENDPOINT');
|
||||||
|
const region = this.config.get<string>('S3_REGION', 'us-east-1');
|
||||||
|
const forcePathStyle =
|
||||||
|
this.config.get<string>('S3_FORCE_PATH_STYLE', 'true') === 'true';
|
||||||
|
|
||||||
|
this.bucket = this.config.getOrThrow<string>('S3_BUCKET');
|
||||||
|
this.publicUrlBase = this.config
|
||||||
|
.getOrThrow<string>('S3_PUBLIC_URL')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
|
||||||
|
this.client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: this.config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
|
||||||
|
secretAccessKey: this.config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(input: UploadObjectInput): Promise<StoredObject> {
|
||||||
|
const key = input.key.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
await this.client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: input.body,
|
||||||
|
ContentType: input.contentType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageDisk: this.storageDisk,
|
||||||
|
storagePath: key,
|
||||||
|
publicUrl: `${this.publicUrlBase}/${key}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuffer(storagePath: string): Promise<Buffer> {
|
||||||
|
const key = storagePath.replace(/^\/+/, '');
|
||||||
|
const result = await this.client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.Body) {
|
||||||
|
throw new Error(`Empty object body for ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(await result.Body.transformToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(storagePath: string): Promise<void> {
|
||||||
|
const key = storagePath.replace(/^\/+/, '');
|
||||||
|
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Parspack S3 object key layout (same bucket as Mado/Balout):
|
||||||
|
*
|
||||||
|
* mana-collection/
|
||||||
|
* products/main/{uuid}{ext}
|
||||||
|
* products/gallery/{uuid}{ext}
|
||||||
|
* blogs/{uuid}{ext}
|
||||||
|
* brands/{uuid}{ext}
|
||||||
|
* users/{uuid}{ext}
|
||||||
|
* stores/{uuid}{ext}
|
||||||
|
* temp/{uuid}{ext}
|
||||||
|
*/
|
||||||
|
const ROOT = 'mana-collection';
|
||||||
|
|
||||||
|
export function productMainKey(fileName: string): string {
|
||||||
|
return `${ROOT}/products/main/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productGalleryKey(fileName: string): string {
|
||||||
|
return `${ROOT}/products/gallery/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/blogs/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brandMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/brands/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/users/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/stores/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tempMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/temp/${fileName}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { S3StorageDriver } from './s3-storage.driver';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [S3StorageDriver, StorageService],
|
||||||
|
exports: [StorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { S3StorageDriver } from './s3-storage.driver';
|
||||||
|
import { StoredObject, UploadObjectInput } from './storage.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService {
|
||||||
|
constructor(private readonly s3: S3StorageDriver) {}
|
||||||
|
|
||||||
|
upload(input: UploadObjectInput): Promise<StoredObject> {
|
||||||
|
return this.s3.upload(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBuffer(storagePath: string): Promise<Buffer> {
|
||||||
|
return this.s3.getBuffer(storagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(storagePath: string): Promise<void> {
|
||||||
|
return this.s3.delete(storagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type StoredObject = {
|
||||||
|
storageDisk: string;
|
||||||
|
storagePath: string;
|
||||||
|
publicUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadObjectInput = {
|
||||||
|
key: string;
|
||||||
|
body: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class StoreRegisterSendCodeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
storeNameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
storeNameEn!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message: 'اسلاگ باید فقط حروف کوچک انگلیسی، عدد و خط تیره باشد',
|
||||||
|
})
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoreRegisterVerifyDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4)
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreMeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
logoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
logoKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreStatusDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^(pending|active|rejected|suspended)$/)
|
||||||
|
status!: 'pending' | 'active' | 'rejected' | 'suspended';
|
||||||
|
|
||||||
|
@ValidateIf((o: UpdateStoreStatusDto) => o.status === 'rejected')
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
rejectionReason?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { StoreStatus, UserRole } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import {
|
||||||
|
StoreRegisterSendCodeDto,
|
||||||
|
StoreRegisterVerifyDto,
|
||||||
|
UpdateStoreMeDto,
|
||||||
|
UpdateStoreStatusDto,
|
||||||
|
} from './dto/store.dto';
|
||||||
|
import { StoresService } from './stores.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class StoresController {
|
||||||
|
constructor(private readonly stores: StoresService) {}
|
||||||
|
|
||||||
|
@Post('public/stores/register/send-code')
|
||||||
|
registerSendCode(@Body() dto: StoreRegisterSendCodeDto) {
|
||||||
|
return this.stores.registerSendCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('public/stores/register/verify')
|
||||||
|
registerVerify(@Body() dto: StoreRegisterVerifyDto) {
|
||||||
|
return this.stores.registerVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stores/me')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN)
|
||||||
|
getMe(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.stores.getMe(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('stores/me')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.STORE_ADMIN)
|
||||||
|
updateMe(@CurrentUser() user: AuthUser, @Body() dto: UpdateStoreMeDto) {
|
||||||
|
return this.stores.updateMe(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/stores')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.SUPER_ADMIN)
|
||||||
|
listAdmin(@Query('status') status?: StoreStatus) {
|
||||||
|
return this.stores.listAdmin(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/stores/:id/status')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.SUPER_ADMIN)
|
||||||
|
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||||
|
return this.stores.updateStatus(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { SmsModule } from '../sms/sms.module';
|
||||||
|
import { StoresController } from './stores.controller';
|
||||||
|
import { StoresService } from './stores.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, SmsModule],
|
||||||
|
controllers: [StoresController],
|
||||||
|
providers: [StoresService, StoreScopeService],
|
||||||
|
exports: [StoresService, StoreScopeService],
|
||||||
|
})
|
||||||
|
export class StoresModule {}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
SmsOtpPurpose,
|
||||||
|
StoreStatus,
|
||||||
|
UserRole,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SmsService } from '../sms/sms.service';
|
||||||
|
import {
|
||||||
|
StoreRegisterSendCodeDto,
|
||||||
|
StoreRegisterVerifyDto,
|
||||||
|
UpdateStoreMeDto,
|
||||||
|
UpdateStoreStatusDto,
|
||||||
|
} from './dto/store.dto';
|
||||||
|
|
||||||
|
type StoreRegisterPayload = {
|
||||||
|
storeNameFa: string;
|
||||||
|
storeNameEn: string;
|
||||||
|
slug: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
passwordHash: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoresService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auth: AuthService,
|
||||||
|
private readonly storeScope: StoreScopeService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async registerSendCode(dto: StoreRegisterSendCodeDto) {
|
||||||
|
const slug = dto.slug.trim().toLowerCase();
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSlug = await this.prisma.store.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingSlug) {
|
||||||
|
throw new ConflictException('این اسلاگ قبلاً استفاده شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
const payload: StoreRegisterPayload = {
|
||||||
|
storeNameFa: dto.storeNameFa.trim(),
|
||||||
|
storeNameEn: dto.storeNameEn.trim(),
|
||||||
|
slug,
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
passwordHash,
|
||||||
|
description: dto.description?.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.auth.createAndSendOtpPublic({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.storeRegister,
|
||||||
|
payload,
|
||||||
|
message: (code) => `کد ثبت فروشگاه مانا کالکشن: ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerVerify(dto: StoreRegisterVerifyDto) {
|
||||||
|
const otp = await this.auth.assertValidOtpPublic({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.storeRegister,
|
||||||
|
code: dto.code,
|
||||||
|
consume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = otp.payload as StoreRegisterPayload | null;
|
||||||
|
if (
|
||||||
|
!payload?.storeNameFa ||
|
||||||
|
!payload?.slug ||
|
||||||
|
!payload?.firstName ||
|
||||||
|
!payload?.lastName ||
|
||||||
|
!payload?.passwordHash
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'اطلاعات ثبت فروشگاه نامعتبر است. دوباره شروع کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSlug = await this.prisma.store.findUnique({
|
||||||
|
where: { slug: payload.slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingSlug) {
|
||||||
|
throw new ConflictException('این اسلاگ قبلاً استفاده شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const user = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
firstName: payload.firstName,
|
||||||
|
lastName: payload.lastName,
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
passwordHash: payload.passwordHash,
|
||||||
|
role: UserRole.STORE_ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await tx.store.create({
|
||||||
|
data: {
|
||||||
|
slug: payload.slug,
|
||||||
|
nameFa: payload.storeNameFa,
|
||||||
|
nameEn: payload.storeNameEn ?? '',
|
||||||
|
description: payload.description,
|
||||||
|
status: StoreStatus.pending,
|
||||||
|
ownerId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, store };
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sms.notifyAdmins(
|
||||||
|
`درخواست فروشگاه جدید «${result.store.nameFa}» از ${result.user.firstName} ${result.user.lastName} (${result.user.cellNumber}) ثبت شد.\nمانا کالکشن.`,
|
||||||
|
result.user.cellNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.auth.issueTokensPublic({
|
||||||
|
...result.user,
|
||||||
|
ownedStore: { id: result.store.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe(actor: AuthUser) {
|
||||||
|
const store = await this.storeScope.requireOwnedStore(actor);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMe(actor: AuthUser, dto: UpdateStoreMeDto) {
|
||||||
|
const store = await this.storeScope.requireOwnedStore(actor);
|
||||||
|
return this.prisma.store.update({
|
||||||
|
where: { id: store.id },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa.trim() } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}),
|
||||||
|
...(dto.description !== undefined
|
||||||
|
? { description: dto.description }
|
||||||
|
: {}),
|
||||||
|
...(dto.logoUrl !== undefined ? { logoUrl: dto.logoUrl } : {}),
|
||||||
|
...(dto.logoKey !== undefined ? { logoKey: dto.logoKey } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listAdmin(status?: StoreStatus) {
|
||||||
|
return this.prisma.store.findMany({
|
||||||
|
where: status ? { status } : undefined,
|
||||||
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
cellNumber: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, dto: UpdateStoreStatusDto) {
|
||||||
|
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||||
|
if (!store) {
|
||||||
|
throw new NotFoundException('فروشگاه یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.store.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: dto.status as StoreStatus,
|
||||||
|
rejectionReason:
|
||||||
|
dto.status === 'rejected'
|
||||||
|
? (dto.rejectionReason ?? store.rejectionReason)
|
||||||
|
: dto.status === 'active'
|
||||||
|
? null
|
||||||
|
: store.rejectionReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "scripts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user