Initial commit: Mana Collection NestJS backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-07 20:49:55 +03:30
co-authored by Cursor
commit fe78a99b78
87 changed files with 10557 additions and 0 deletions
+39
View File
@@ -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=
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.env
*.log
.DS_Store
scripts/data/
.idea/
+34
View File
@@ -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`.
+61
View File
@@ -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.
+25
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+5601
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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[];
+2
View File
@@ -0,0 +1,2 @@
-- Prisma migration lock
provider = "postgresql"
+287
View File
@@ -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])
}
+63
View File
@@ -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();
});
+114
View File
@@ -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();
});
+53
View File
@@ -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);
});
+39
View File
@@ -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 {}
+73
View File
@@ -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);
}
}
+33
View File
@@ -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 {}
+488
View File
@@ -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;
}
}
+25
View File
@@ -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;
},
);
+5
View File
@@ -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);
+7
View File
@@ -0,0 +1,7 @@
import { IsString, Matches } from 'class-validator';
export class CellNumberDto {
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber!: string;
}
+16
View File
@@ -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;
}
+11
View File
@@ -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;
}
+12
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(20)
refreshToken!: string;
}
+19
View File
@@ -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;
}
+12
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+35
View File
@@ -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;
}
}
+47
View File
@@ -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,
};
}
}
+58
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+103
View File
@@ -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('مطلب یافت نشد');
}
}
}
+95
View File
@@ -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;
}
+53
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+79
View File
@@ -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('برند یافت نشد');
}
}
}
+42
View File
@@ -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;
}
+60
View File
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CatalogController } from './catalog.controller';
@Module({
controllers: [CatalogController],
})
export class CatalogModule {}
+53
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+107
View File
@@ -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('دسته‌بندی یافت نشد');
}
}
}
+81
View File
@@ -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[];
}
+9
View File
@@ -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, '');
}
+76
View File
@@ -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;
}
}
+20
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+91
View File
@@ -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
View File
@@ -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();
+17
View File
@@ -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;
}
+36
View File
@@ -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);
}
}
+12
View 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 {}
+94
View File
@@ -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('نوع فایل نامعتبر است');
}
}
}
+43
View File
@@ -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;
}
+56
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+130
View File
@@ -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 },
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -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();
}
}
+181
View File
@@ -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[];
}
+71
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+190
View File
@@ -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('محصول یافت نشد');
}
}
}
+80
View File
@@ -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;
}
+62
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+123
View File
@@ -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;
}
}
+41
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+113
View File
@@ -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' },
});
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}
+92
View File
@@ -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 };
}
}
+9
View File
@@ -0,0 +1,9 @@
export type SendSmsInput = {
to: string;
message: string;
};
export type SendSmsResult = {
success: true;
serverId: string;
};
+85
View File
@@ -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,
}),
);
}
}
+41
View File
@@ -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}`;
}
+9
View File
@@ -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 {}
+20
View File
@@ -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);
}
}
+11
View File
@@ -0,0 +1,11 @@
export type StoredObject = {
storageDisk: string;
storagePath: string;
publicUrl: string;
};
export type UploadObjectInput = {
key: string;
body: Buffer;
contentType: string;
};
+88
View File
@@ -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;
}
+66
View File
@@ -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);
}
}
+14
View File
@@ -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 {}
+211
View File
@@ -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,
},
});
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "scripts"]
}
+25
View File
@@ -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
}
}