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