Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Environment template — copy to apps/api/.env for local dev
|
||||
DATABASE_URL="postgresql://mento:mento@localhost:5432/mento_crm?schema=public"
|
||||
JWT_SECRET="change-me-in-production-use-long-random-string"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
PORT=3000
|
||||
CORS_ORIGIN="http://localhost:5173"
|
||||
UPLOAD_DIR="./uploads"
|
||||
|
||||
# Portal OTP: در لوکال معمولاً true تا کد در پاسخ بیاید
|
||||
PORTAL_SHOW_OTP=true
|
||||
# AUTH_RETURN_OTP=true
|
||||
|
||||
# SMS — پنل ترِز / رایگان (مثل montime). اختیاری در لوکال اگر PORTAL_SHOW_OTP=true
|
||||
SMS_PROVIDER=trez
|
||||
SMS_USERNAME=
|
||||
SMS_PASSWORD=
|
||||
SMS_SENDER=
|
||||
# SMS_BASE_URL=http://smspanel.Trez.ir
|
||||
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||
# SMS_OTP_TEMPLATE=کد ورود پرتال منتو: {code}
|
||||
# SMS_TIMEOUT_MS=20000
|
||||
|
||||
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
coverage/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
apps/api/prisma/*.db
|
||||
apps/api/prisma/*.db-journal
|
||||
uploads/
|
||||
deploy/.env
|
||||
apps/api/.env
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,123 @@
|
||||
# Mento CRM (منتو)
|
||||
|
||||
Monorepo برای CRM فروشمحور ایرانی با همترازی قابلیت نسبت به دیدار، بهاضافه Smart Notes (سند کارفرما).
|
||||
|
||||
## استک
|
||||
|
||||
| بخش | فناوری |
|
||||
|---|---|
|
||||
| پنل | Vue 3 + Vite + TypeScript + Pinia |
|
||||
| API | NestJS |
|
||||
| دیتابیس | PostgreSQL (Prisma) |
|
||||
| صف/کش | Redis (آماده در Compose) |
|
||||
| فایل | MinIO (آماده در Compose) |
|
||||
| لندینگ | بعداً با Nuxt در `apps/landing` |
|
||||
|
||||
UI را پیکسلبهپیکسل از دیدار کپی نمیکنیم؛ هویت بصری مستقل **منتو (Mento)** است.
|
||||
|
||||
## ساختار
|
||||
|
||||
```text
|
||||
apps/
|
||||
web/ # پنل Vue
|
||||
api/ # NestJS
|
||||
packages/
|
||||
shared/ # تایپها و قرارداد مشترک
|
||||
docs/ # نقشه راه و PRD
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
## پیشنیاز
|
||||
|
||||
- Node.js 20+
|
||||
- PostgreSQL روی `localhost:5432` یا Docker Desktop
|
||||
- (اختیاری) Redis و MinIO از Compose
|
||||
|
||||
اگر Docker دارید:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## راهاندازی
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build -w @mento/shared
|
||||
npm run prisma:generate -w @mento/api
|
||||
npm run prisma:migrate -w @mento/api
|
||||
npm run prisma:seed -w @mento/api
|
||||
```
|
||||
|
||||
ترمینال ۱:
|
||||
|
||||
```bash
|
||||
npm run dev:api
|
||||
```
|
||||
|
||||
ترمینال ۲:
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
- پنل: http://localhost:5173
|
||||
- API: http://localhost:3000/api/health
|
||||
|
||||
حساب دمو بعد از seed:
|
||||
|
||||
- مالک: `owner@demo.local` / `demo1234`
|
||||
- مدیر: `admin@demo.local` / `demo1234`
|
||||
- فروش: `sales@demo.local` / `demo1234`
|
||||
|
||||
## فاز فعلی
|
||||
|
||||
جزئیات تیکخورده و باقیمانده:
|
||||
|
||||
- [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چکلیست زنده**
|
||||
|
||||
مایلاستون ۱-الف تقریباً کامل:
|
||||
|
||||
- ✅ ورود / سازمان
|
||||
- ✅ اشخاص و شرکتها (جستجو + جلوگیری از تکراری)
|
||||
- ✅ کاریز فروش کانبان (معامله، کشیدن بین مراحل)
|
||||
- ✅ فعالیتها (امروز / عقبافتاده)
|
||||
- ✅ Smart Notes پایه (ثبت سریع، پین، تگ، اتصال، تبدیل به وظیفه)
|
||||
- ✅ فیلد سفارشی (تعریف + مقدار روی شخص/شرکت/معامله)
|
||||
- ✅ ایمپورت/اکسپورت اکسل مخاطبین
|
||||
- ✅ غنیسازی پرونده معامله (برچسب، راکد/فاسد، منبع، دلیل شکست)
|
||||
- ✅ محصول/خدمت روی معامله (کاتالوگ + ردیف و جمع مبلغ)
|
||||
- ✅ نوتیفیکیشن داخل برنامه (زنگ اعلان، خوانده/نخوانده، رویداد فعالیت و معامله)
|
||||
- ✅ تایملاین پرونده (یادداشت + فعالیت روی شخص/شرکت/معامله)
|
||||
- ✅ تقویم فعالیتها (نمای ماهانه + فیلتر بازه تاریخ)
|
||||
- ✅ چند کاریز فروش (انتخاب کاریز + ساخت کاریز جدید)
|
||||
- ✅ انواع Note (فیلتر + برچسب نوع روی کارت)
|
||||
- ✅ پیوست فایل روی Note (آپلود / دانلود / حذف)
|
||||
- ✅ نقش و دسترسی سازمانی (تیم، RolesGuard، تنظیمات فقط مالک/مدیر)
|
||||
- ✅ سطح دسترسی پروندهای (فروش/پشتیبانی فقط مالکیت خود)
|
||||
- ✅ هدفگذاری و پیشبینی فروش پایه
|
||||
- ✅ چندارزی واقعی (ارز پایه + نرخ + انتخاب در فرمها)
|
||||
- ✅ تیکتینگ پایه (گفتگو + ارجاع)
|
||||
- ✅ SLA + FAQ + CSAT
|
||||
- ✅ پرتال مشتری OTP (`/p/demo-mento` + SMS ترِز)
|
||||
- ✅ گزارشات و داشبورد
|
||||
- استقرار: [`deploy/DEPLOY.md`](deploy/DEPLOY.md) → `crm.montzar.ir`
|
||||
- ✅ فرمساز سرنخ (لینک عمومی / iframe / QR)
|
||||
- ✅ پیشفاکتور (از معامله یا مستقل + چاپ)
|
||||
- ✅ فاکتور / پرداخت / اقساط ساده
|
||||
|
||||
## راهنمای کاربری
|
||||
|
||||
برای کار با پنل (زبان ساده، گامبهگام):
|
||||
|
||||
- [`docs/راهنمای-کاربری.md`](docs/راهنمای-کاربری.md)
|
||||
- [`docs/راهنمای-کاربری.docx`](docs/راهنمای-کاربری.docx)
|
||||
- [`docs/راهنمای-کاربری.pdf`](docs/راهنمای-کاربری.pdf)
|
||||
|
||||
برای بازتولید Word/PDF:
|
||||
|
||||
```bash
|
||||
python docs/export-doc.py --preset user-guide
|
||||
```
|
||||
|
||||
سپس در صورت نیاز همان DOCX را از Word به PDF Export کنید.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@mento/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
"@nestjs/common": "^11.0.12",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.12",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.12",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.5",
|
||||
"@nestjs/schematics": "^11.0.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"prisma": "^6.5.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('owner', 'admin', 'sales', 'support');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Organization" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'sales',
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_organizationId_idx" ON "User"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_organizationId_email_key" ON "User"("organizationId", "email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Company" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nationalId" TEXT,
|
||||
"phone" TEXT,
|
||||
"email" TEXT,
|
||||
"website" TEXT,
|
||||
"address" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Company_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Person" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"companyId" TEXT,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"mobile" TEXT,
|
||||
"position" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Person_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Company_organizationId_idx" ON "Company"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Company_organizationId_name_idx" ON "Company"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Company_organizationId_email_idx" ON "Company"("organizationId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Company_organizationId_phone_idx" ON "Company"("organizationId", "phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_organizationId_idx" ON "Person"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_organizationId_email_idx" ON "Person"("organizationId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_organizationId_mobile_idx" ON "Person"("organizationId", "mobile");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_organizationId_lastName_firstName_idx" ON "Person"("organizationId", "lastName", "firstName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_companyId_idx" ON "Person"("companyId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Company" ADD CONSTRAINT "Company_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Person" ADD CONSTRAINT "Person_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Person" ADD CONSTRAINT "Person_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DealStatus" AS ENUM ('open', 'won', 'lost');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Pipeline" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Pipeline_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PipelineStage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"pipelineId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"isWon" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isLost" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PipelineStage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Deal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"pipelineId" TEXT NOT NULL,
|
||||
"stageId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2),
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"status" "DealStatus" NOT NULL DEFAULT 'open',
|
||||
"source" TEXT,
|
||||
"lostReason" TEXT,
|
||||
"notes" TEXT,
|
||||
"expectedCloseAt" TIMESTAMP(3),
|
||||
"personId" TEXT,
|
||||
"companyId" TEXT,
|
||||
"ownerId" TEXT,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Deal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Pipeline_organizationId_idx" ON "Pipeline"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Pipeline_organizationId_isDefault_idx" ON "Pipeline"("organizationId", "isDefault");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PipelineStage_pipelineId_position_idx" ON "PipelineStage"("pipelineId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Deal_organizationId_idx" ON "Deal"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Deal_pipelineId_stageId_idx" ON "Deal"("pipelineId", "stageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Deal_stageId_position_idx" ON "Deal"("stageId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Deal_personId_idx" ON "Deal"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Deal_companyId_idx" ON "Deal"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Deal_ownerId_idx" ON "Deal"("ownerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Pipeline" ADD CONSTRAINT "Pipeline_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PipelineStage" ADD CONSTRAINT "PipelineStage_pipelineId_fkey" FOREIGN KEY ("pipelineId") REFERENCES "Pipeline"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_pipelineId_fkey" FOREIGN KEY ("pipelineId") REFERENCES "Pipeline"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "PipelineStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityType" AS ENUM ('call', 'meeting', 'task', 'note');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityStatus" AS ENUM ('open', 'done', 'canceled');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Activity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"type" "ActivityType" NOT NULL DEFAULT 'task',
|
||||
"status" "ActivityStatus" NOT NULL DEFAULT 'open',
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"dueAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"personId" TEXT,
|
||||
"companyId" TEXT,
|
||||
"dealId" TEXT,
|
||||
"assigneeId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_organizationId_status_dueAt_idx" ON "Activity"("organizationId", "status", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_organizationId_assigneeId_idx" ON "Activity"("organizationId", "assigneeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_personId_idx" ON "Activity"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_companyId_idx" ON "Activity"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_dealId_idx" ON "Activity"("dealId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Note" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT,
|
||||
"body" TEXT NOT NULL,
|
||||
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"color" TEXT DEFAULT 'default',
|
||||
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"reminderAt" TIMESTAMP(3),
|
||||
"personId" TEXT,
|
||||
"companyId" TEXT,
|
||||
"dealId" TEXT,
|
||||
"authorId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_organizationId_pinned_updatedAt_idx" ON "Note"("organizationId", "pinned", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_organizationId_reminderAt_idx" ON "Note"("organizationId", "reminderAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_personId_idx" ON "Note"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_companyId_idx" ON "Note"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_dealId_idx" ON "Note"("dealId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_authorId_idx" ON "Note"("authorId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Note" ADD CONSTRAINT "Note_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CustomFieldEntity" AS ENUM ('person', 'company', 'deal');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CustomFieldType" AS ENUM ('text', 'number', 'date', 'boolean', 'select');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Company" ADD COLUMN "customFields" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Deal" ADD COLUMN "customFields" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "customFields" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CustomFieldDefinition" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"entityType" "CustomFieldEntity" NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"fieldType" "CustomFieldType" NOT NULL DEFAULT 'text',
|
||||
"options" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"required" BOOLEAN NOT NULL DEFAULT false,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CustomFieldDefinition_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CustomFieldDefinition_organizationId_entityType_position_idx" ON "CustomFieldDefinition"("organizationId", "entityType", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CustomFieldDefinition_organizationId_entityType_key_key" ON "CustomFieldDefinition"("organizationId", "entityType", "key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomFieldDefinition" ADD CONSTRAINT "CustomFieldDefinition_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Deal" ADD COLUMN "isRotten" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "isStale" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,52 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"sku" TEXT,
|
||||
"description" TEXT,
|
||||
"unitPrice" DECIMAL(18,2),
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"isActive" 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 "DealLineItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"dealId" TEXT NOT NULL,
|
||||
"productId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"quantity" DECIMAL(18,2) NOT NULL DEFAULT 1,
|
||||
"unitPrice" DECIMAL(18,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DealLineItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_organizationId_name_idx" ON "Product"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_organizationId_isActive_idx" ON "Product"("organizationId", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DealLineItem_dealId_position_idx" ON "DealLineItem"("dealId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DealLineItem_productId_idx" ON "DealLineItem"("productId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DealLineItem" ADD CONSTRAINT "DealLineItem_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DealLineItem" ADD CONSTRAINT "DealLineItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Notification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT,
|
||||
"link" TEXT,
|
||||
"entityType" TEXT,
|
||||
"entityId" TEXT,
|
||||
"readAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_userId_readAt_createdAt_idx" ON "Notification"("userId", "readAt", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_organizationId_createdAt_idx" ON "Notification"("organizationId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_userId_type_entityId_idx" ON "Notification"("userId", "type", "entityId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "noteType" TEXT NOT NULL DEFAULT 'general';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_organizationId_noteType_updatedAt_idx" ON "Note"("organizationId", "noteType", "updatedAt");
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoteAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"noteId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"uploadedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NoteAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoteAttachment_noteId_createdAt_idx" ON "NoteAttachment"("noteId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoteAttachment_organizationId_idx" ON "NoteAttachment"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoteAttachment_uploadedById_idx" ON "NoteAttachment"("uploadedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteAttachment" ADD CONSTRAINT "NoteAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteAttachment" ADD CONSTRAINT "NoteAttachment_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteAttachment" ADD CONSTRAINT "NoteAttachment_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LeadForm" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"publicToken" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createPerson" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createDeal" BOOLEAN NOT NULL DEFAULT true,
|
||||
"pipelineId" TEXT,
|
||||
"source" TEXT,
|
||||
"thankYouMessage" TEXT,
|
||||
"redirectUrl" TEXT,
|
||||
"assigneeIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"roundRobinIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LeadForm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LeadFormField" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"fieldType" TEXT NOT NULL DEFAULT 'text',
|
||||
"required" BOOLEAN NOT NULL DEFAULT false,
|
||||
"mapTo" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "LeadFormField_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LeadForm_publicToken_key" ON "LeadForm"("publicToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LeadForm_organizationId_updatedAt_idx" ON "LeadForm"("organizationId", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LeadFormField_formId_position_idx" ON "LeadFormField"("formId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LeadFormField_formId_key_key" ON "LeadFormField"("formId", "key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LeadForm" ADD CONSTRAINT "LeadForm_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LeadFormField" ADD CONSTRAINT "LeadFormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "LeadForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,83 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "QuoteStatus" AS ENUM ('draft', 'sent', 'accepted', 'rejected', 'expired');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Quote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "QuoteStatus" NOT NULL DEFAULT 'draft',
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"notes" TEXT,
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"personId" TEXT,
|
||||
"companyId" TEXT,
|
||||
"dealId" TEXT,
|
||||
"ownerId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Quote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "QuoteLineItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"quoteId" TEXT NOT NULL,
|
||||
"productId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"quantity" DECIMAL(18,2) NOT NULL DEFAULT 1,
|
||||
"unitPrice" DECIMAL(18,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "QuoteLineItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Quote_organizationId_status_updatedAt_idx" ON "Quote"("organizationId", "status", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Quote_personId_idx" ON "Quote"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Quote_companyId_idx" ON "Quote"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Quote_dealId_idx" ON "Quote"("dealId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Quote_ownerId_idx" ON "Quote"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Quote_organizationId_number_key" ON "Quote"("organizationId", "number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "QuoteLineItem_quoteId_position_idx" ON "QuoteLineItem"("quoteId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "QuoteLineItem_productId_idx" ON "QuoteLineItem"("productId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Quote" ADD CONSTRAINT "Quote_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Quote" ADD CONSTRAINT "Quote_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Quote" ADD CONSTRAINT "Quote_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Quote" ADD CONSTRAINT "Quote_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Quote" ADD CONSTRAINT "Quote_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QuoteLineItem" ADD CONSTRAINT "QuoteLineItem_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "Quote"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "QuoteLineItem" ADD CONSTRAINT "QuoteLineItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,153 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InvoiceStatus" AS ENUM ('draft', 'issued', 'partially_paid', 'paid', 'canceled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentMethod" AS ENUM ('cash', 'transfer', 'card', 'check', 'other');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InstallmentStatus" AS ENUM ('pending', 'paid', 'canceled');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Invoice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "InvoiceStatus" NOT NULL DEFAULT 'draft',
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"notes" TEXT,
|
||||
"issuedAt" TIMESTAMP(3),
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"personId" TEXT,
|
||||
"companyId" TEXT,
|
||||
"dealId" TEXT,
|
||||
"quoteId" TEXT,
|
||||
"ownerId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvoiceLineItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"productId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"quantity" DECIMAL(18,2) NOT NULL DEFAULT 1,
|
||||
"unitPrice" DECIMAL(18,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InvoiceLineItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Payment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"method" "PaymentMethod" NOT NULL DEFAULT 'transfer',
|
||||
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"notes" TEXT,
|
||||
"installmentId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvoiceInstallment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"amount" DECIMAL(18,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"status" "InstallmentStatus" NOT NULL DEFAULT 'pending',
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InvoiceInstallment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Invoice_organizationId_status_updatedAt_idx" ON "Invoice"("organizationId", "status", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Invoice_personId_idx" ON "Invoice"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Invoice_companyId_idx" ON "Invoice"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Invoice_dealId_idx" ON "Invoice"("dealId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Invoice_quoteId_idx" ON "Invoice"("quoteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Invoice_ownerId_idx" ON "Invoice"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Invoice_organizationId_number_key" ON "Invoice"("organizationId", "number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InvoiceLineItem_invoiceId_position_idx" ON "InvoiceLineItem"("invoiceId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InvoiceLineItem_productId_idx" ON "InvoiceLineItem"("productId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Payment_installmentId_key" ON "Payment"("installmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_invoiceId_paidAt_idx" ON "Payment"("invoiceId", "paidAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InvoiceInstallment_invoiceId_position_idx" ON "InvoiceInstallment"("invoiceId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InvoiceInstallment_invoiceId_dueDate_idx" ON "InvoiceInstallment"("invoiceId", "dueDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InvoiceInstallment_status_dueDate_idx" ON "InvoiceInstallment"("status", "dueDate");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "Quote"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvoiceLineItem" ADD CONSTRAINT "InvoiceLineItem_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvoiceLineItem" ADD CONSTRAINT "InvoiceLineItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_installmentId_fkey" FOREIGN KEY ("installmentId") REFERENCES "InvoiceInstallment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvoiceInstallment" ADD CONSTRAINT "InvoiceInstallment_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Company" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Company_ownerId_idx" ON "Company"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Person_ownerId_idx" ON "Person"("ownerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Company" ADD CONSTRAINT "Company_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Person" ADD CONSTRAINT "Person_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SalesGoal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"periodStart" TIMESTAMP(3) NOT NULL,
|
||||
"periodEnd" TIMESTAMP(3) NOT NULL,
|
||||
"targetAmount" DECIMAL(18,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SalesGoal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SalesGoal_organizationId_periodStart_periodEnd_idx" ON "SalesGoal"("organizationId", "periodStart", "periodEnd");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SalesGoal_userId_idx" ON "SalesGoal"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SalesGoal" ADD CONSTRAINT "SalesGoal_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SalesGoal" ADD CONSTRAINT "SalesGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "baseCurrency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
ADD COLUMN "enabledCurrencies" TEXT[] DEFAULT ARRAY['IRR', 'USD', 'EUR']::TEXT[];
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExchangeRate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"currency" TEXT NOT NULL,
|
||||
"rateToBase" DECIMAL(24,6) NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ExchangeRate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ExchangeRate_organizationId_idx" ON "ExchangeRate"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ExchangeRate_organizationId_currency_key" ON "ExchangeRate"("organizationId", "currency");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExchangeRate" ADD CONSTRAINT "ExchangeRate_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,89 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketStatus" AS ENUM ('open', 'in_progress', 'waiting', 'resolved', 'closed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketPriority" AS ENUM ('low', 'normal', 'high', 'urgent');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Ticket" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "TicketStatus" NOT NULL DEFAULT 'open',
|
||||
"priority" "TicketPriority" NOT NULL DEFAULT 'normal',
|
||||
"personId" TEXT,
|
||||
"companyId" TEXT,
|
||||
"dealId" TEXT,
|
||||
"assigneeId" TEXT,
|
||||
"reporterId" TEXT,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"authorId" TEXT,
|
||||
"body" TEXT NOT NULL,
|
||||
"isInternal" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TicketMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_organizationId_status_updatedAt_idx" ON "Ticket"("organizationId", "status", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_assigneeId_idx" ON "Ticket"("assigneeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_reporterId_idx" ON "Ticket"("reporterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_personId_idx" ON "Ticket"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_companyId_idx" ON "Ticket"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_dealId_idx" ON "Ticket"("dealId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ticket_organizationId_number_key" ON "Ticket"("organizationId", "number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketMessage_ticketId_createdAt_idx" ON "TicketMessage"("ticketId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketMessage_authorId_idx" ON "TicketMessage"("authorId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketMessage" ADD CONSTRAINT "TicketMessage_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketMessage" ADD CONSTRAINT "TicketMessage_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ADD COLUMN "csatAt" TIMESTAMP(3),
|
||||
ADD COLUMN "csatComment" TEXT,
|
||||
ADD COLUMN "csatScore" INTEGER,
|
||||
ADD COLUMN "firstRespondedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "slaDueAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FaqArticle" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"question" TEXT NOT NULL,
|
||||
"answer" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FaqArticle_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FaqArticle_organizationId_isPublished_position_idx" ON "FaqArticle"("organizationId", "isPublished", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_organizationId_slaDueAt_idx" ON "Ticket"("organizationId", "slaDueAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PortalOtp" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PortalOtp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PortalOtp_organizationId_mobile_createdAt_idx" ON "PortalOtp"("organizationId", "mobile", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PortalOtp_expiresAt_idx" ON "PortalOtp"("expiresAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PortalOtp" ADD CONSTRAINT "PortalOtp_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,738 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
owner
|
||||
admin
|
||||
sales
|
||||
support
|
||||
}
|
||||
|
||||
enum DealStatus {
|
||||
open
|
||||
won
|
||||
lost
|
||||
}
|
||||
|
||||
enum ActivityType {
|
||||
call
|
||||
meeting
|
||||
task
|
||||
note
|
||||
}
|
||||
|
||||
enum ActivityStatus {
|
||||
open
|
||||
done
|
||||
canceled
|
||||
}
|
||||
|
||||
enum CustomFieldEntity {
|
||||
person
|
||||
company
|
||||
deal
|
||||
}
|
||||
|
||||
enum CustomFieldType {
|
||||
text
|
||||
number
|
||||
date
|
||||
boolean
|
||||
select
|
||||
}
|
||||
|
||||
enum QuoteStatus {
|
||||
draft
|
||||
sent
|
||||
accepted
|
||||
rejected
|
||||
expired
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
draft
|
||||
issued
|
||||
partially_paid
|
||||
paid
|
||||
canceled
|
||||
}
|
||||
|
||||
enum PaymentMethod {
|
||||
cash
|
||||
transfer
|
||||
card
|
||||
check
|
||||
other
|
||||
}
|
||||
|
||||
enum InstallmentStatus {
|
||||
pending
|
||||
paid
|
||||
canceled
|
||||
}
|
||||
|
||||
enum TicketStatus {
|
||||
open
|
||||
in_progress
|
||||
waiting
|
||||
resolved
|
||||
closed
|
||||
}
|
||||
|
||||
enum TicketPriority {
|
||||
low
|
||||
normal
|
||||
high
|
||||
urgent
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
baseCurrency String @default("IRR")
|
||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
companies Company[]
|
||||
people Person[]
|
||||
pipelines Pipeline[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
notes Note[]
|
||||
customFields CustomFieldDefinition[]
|
||||
products Product[]
|
||||
notifications Notification[]
|
||||
noteAttachments NoteAttachment[]
|
||||
leadForms LeadForm[]
|
||||
quotes Quote[]
|
||||
invoices Invoice[]
|
||||
salesGoals SalesGoal[]
|
||||
exchangeRates ExchangeRate[]
|
||||
tickets Ticket[]
|
||||
faqArticles FaqArticle[]
|
||||
portalOtps PortalOtp[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
passwordHash String
|
||||
fullName String
|
||||
role UserRole @default(sales)
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
ownedDeals Deal[] @relation("DealOwner")
|
||||
ownedQuotes Quote[] @relation("QuoteOwner")
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
notes Note[] @relation("NoteAuthor")
|
||||
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
||||
notifications Notification[]
|
||||
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||
reportedTickets Ticket[] @relation("TicketReporter")
|
||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, email])
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
model Company {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
nationalId String?
|
||||
phone String?
|
||||
email String?
|
||||
website String?
|
||||
address String?
|
||||
notes String?
|
||||
customFields Json @default("{}")
|
||||
people Person[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
quotes Quote[]
|
||||
invoices Invoice[]
|
||||
ownerId String?
|
||||
owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationId, name])
|
||||
@@index([organizationId, email])
|
||||
@@index([organizationId, phone])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model Person {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
firstName String
|
||||
lastName String
|
||||
email String?
|
||||
phone String?
|
||||
mobile String?
|
||||
position String?
|
||||
notes String?
|
||||
customFields Json @default("{}")
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
quotes Quote[]
|
||||
invoices Invoice[]
|
||||
ownerId String?
|
||||
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationId, email])
|
||||
@@index([organizationId, mobile])
|
||||
@@index([organizationId, lastName, firstName])
|
||||
@@index([companyId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model Pipeline {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
isDefault Boolean @default(false)
|
||||
stages PipelineStage[]
|
||||
deals Deal[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationId, isDefault])
|
||||
}
|
||||
|
||||
model PipelineStage {
|
||||
id String @id @default(cuid())
|
||||
pipelineId String
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
position Int
|
||||
isWon Boolean @default(false)
|
||||
isLost Boolean @default(false)
|
||||
deals Deal[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([pipelineId, position])
|
||||
}
|
||||
|
||||
model Deal {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
pipelineId String
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
stageId String
|
||||
stage PipelineStage @relation(fields: [stageId], references: [id], onDelete: Restrict)
|
||||
title String
|
||||
amount Decimal? @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
status DealStatus @default(open)
|
||||
source String?
|
||||
lostReason String?
|
||||
notes String?
|
||||
tags String[] @default([])
|
||||
isStale Boolean @default(false)
|
||||
isRotten Boolean @default(false)
|
||||
customFields Json @default("{}")
|
||||
expectedCloseAt DateTime?
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
ownerId String?
|
||||
owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
position Int @default(0)
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
lineItems DealLineItem[]
|
||||
quotes Quote[]
|
||||
invoices Invoice[]
|
||||
tickets Ticket[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([pipelineId, stageId])
|
||||
@@index([stageId, position])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
type ActivityType @default(task)
|
||||
status ActivityStatus @default(open)
|
||||
title String
|
||||
description String?
|
||||
dueAt DateTime?
|
||||
completedAt DateTime?
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
dealId String?
|
||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
assigneeId String?
|
||||
assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, status, dueAt])
|
||||
@@index([organizationId, assigneeId])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([dealId])
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
title String?
|
||||
body String
|
||||
noteType String @default("general")
|
||||
pinned Boolean @default(false)
|
||||
color String? @default("default")
|
||||
tags String[] @default([])
|
||||
reminderAt DateTime?
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
dealId String?
|
||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
authorId String?
|
||||
author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
||||
attachments NoteAttachment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, pinned, updatedAt])
|
||||
@@index([organizationId, noteType, updatedAt])
|
||||
@@index([organizationId, reminderAt])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([dealId])
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
model NoteAttachment {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
noteId String
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
fileName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
storageKey String
|
||||
uploadedById String?
|
||||
uploadedBy User? @relation("NoteAttachmentUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([noteId, createdAt])
|
||||
@@index([organizationId])
|
||||
@@index([uploadedById])
|
||||
}
|
||||
|
||||
model CustomFieldDefinition {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
entityType CustomFieldEntity
|
||||
key String
|
||||
label String
|
||||
fieldType CustomFieldType @default(text)
|
||||
options String[] @default([])
|
||||
required Boolean @default(false)
|
||||
position Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, entityType, key])
|
||||
@@index([organizationId, entityType, position])
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
sku String?
|
||||
description String?
|
||||
unitPrice Decimal? @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
isActive Boolean @default(true)
|
||||
lineItems DealLineItem[]
|
||||
quoteLineItems QuoteLineItem[]
|
||||
invoiceLineItems InvoiceLineItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, name])
|
||||
@@index([organizationId, isActive])
|
||||
}
|
||||
|
||||
model DealLineItem {
|
||||
id String @id @default(cuid())
|
||||
dealId String
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
productId String?
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
quantity Decimal @default(1) @db.Decimal(18, 2)
|
||||
unitPrice Decimal @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([dealId, position])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
type String
|
||||
title String
|
||||
body String?
|
||||
link String?
|
||||
entityType String?
|
||||
entityId String?
|
||||
readAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, readAt, createdAt])
|
||||
@@index([organizationId, createdAt])
|
||||
@@index([userId, type, entityId])
|
||||
}
|
||||
|
||||
model LeadForm {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
publicToken String @unique @default(cuid())
|
||||
isActive Boolean @default(true)
|
||||
createPerson Boolean @default(true)
|
||||
createDeal Boolean @default(true)
|
||||
pipelineId String?
|
||||
source String?
|
||||
thankYouMessage String?
|
||||
redirectUrl String?
|
||||
assigneeIds String[] @default([])
|
||||
roundRobinIndex Int @default(0)
|
||||
fields LeadFormField[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, updatedAt])
|
||||
}
|
||||
|
||||
model LeadFormField {
|
||||
id String @id @default(cuid())
|
||||
formId String
|
||||
form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
key String
|
||||
label String
|
||||
fieldType String @default("text")
|
||||
required Boolean @default(false)
|
||||
mapTo String
|
||||
position Int @default(0)
|
||||
|
||||
@@unique([formId, key])
|
||||
@@index([formId, position])
|
||||
}
|
||||
|
||||
model Quote {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
number String
|
||||
title String
|
||||
status QuoteStatus @default(draft)
|
||||
currency String @default("IRR")
|
||||
notes String?
|
||||
validUntil DateTime?
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
dealId String?
|
||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
ownerId String?
|
||||
owner User? @relation("QuoteOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
lineItems QuoteLineItem[]
|
||||
invoices Invoice[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, number])
|
||||
@@index([organizationId, status, updatedAt])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([dealId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model QuoteLineItem {
|
||||
id String @id @default(cuid())
|
||||
quoteId String
|
||||
quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
|
||||
productId String?
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
quantity Decimal @default(1) @db.Decimal(18, 2)
|
||||
unitPrice Decimal @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([quoteId, position])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
number String
|
||||
title String
|
||||
status InvoiceStatus @default(draft)
|
||||
currency String @default("IRR")
|
||||
notes String?
|
||||
issuedAt DateTime?
|
||||
dueDate DateTime?
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
dealId String?
|
||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
quoteId String?
|
||||
quote Quote? @relation(fields: [quoteId], references: [id], onDelete: SetNull)
|
||||
ownerId String?
|
||||
owner User? @relation("InvoiceOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
lineItems InvoiceLineItem[]
|
||||
payments Payment[]
|
||||
installments InvoiceInstallment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, number])
|
||||
@@index([organizationId, status, updatedAt])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([dealId])
|
||||
@@index([quoteId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model InvoiceLineItem {
|
||||
id String @id @default(cuid())
|
||||
invoiceId String
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
productId String?
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
quantity Decimal @default(1) @db.Decimal(18, 2)
|
||||
unitPrice Decimal @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([invoiceId, position])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
invoiceId String
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
amount Decimal @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
method PaymentMethod @default(transfer)
|
||||
paidAt DateTime @default(now())
|
||||
notes String?
|
||||
installmentId String? @unique
|
||||
installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([invoiceId, paidAt])
|
||||
}
|
||||
|
||||
model InvoiceInstallment {
|
||||
id String @id @default(cuid())
|
||||
invoiceId String
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
|
||||
position Int @default(0)
|
||||
dueDate DateTime
|
||||
amount Decimal @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
status InstallmentStatus @default(pending)
|
||||
paidAt DateTime?
|
||||
payment Payment?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([invoiceId, position])
|
||||
@@index([invoiceId, dueDate])
|
||||
@@index([status, dueDate])
|
||||
}
|
||||
|
||||
model SalesGoal {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
/// null = هدف سازمانی / تیمی
|
||||
userId String?
|
||||
user User? @relation("SalesGoalUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
periodStart DateTime
|
||||
periodEnd DateTime
|
||||
targetAmount Decimal @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, periodStart, periodEnd])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ExchangeRate {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
/// ارز مبدأ؛ ۱ واحد این ارز = rateToBase واحد ارز پایه
|
||||
currency String
|
||||
rateToBase Decimal @db.Decimal(24, 6)
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([organizationId, currency])
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
number String
|
||||
subject String
|
||||
description String?
|
||||
status TicketStatus @default(open)
|
||||
priority TicketPriority @default(normal)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
dealId String?
|
||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
assigneeId String?
|
||||
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
reporterId String?
|
||||
reporter User? @relation("TicketReporter", fields: [reporterId], references: [id], onDelete: SetNull)
|
||||
resolvedAt DateTime?
|
||||
/// مهلت پاسخ اول بر اساس اولویت
|
||||
slaDueAt DateTime?
|
||||
firstRespondedAt DateTime?
|
||||
csatScore Int?
|
||||
csatComment String?
|
||||
csatAt DateTime?
|
||||
messages TicketMessage[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, number])
|
||||
@@index([organizationId, status, updatedAt])
|
||||
@@index([organizationId, slaDueAt])
|
||||
@@index([assigneeId])
|
||||
@@index([reporterId])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([dealId])
|
||||
}
|
||||
|
||||
model TicketMessage {
|
||||
id String @id @default(cuid())
|
||||
ticketId String
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
authorId String?
|
||||
author User? @relation("TicketMessageAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
||||
body String
|
||||
isInternal Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([ticketId, createdAt])
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
model FaqArticle {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
question String
|
||||
answer String
|
||||
category String?
|
||||
isPublished Boolean @default(true)
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, isPublished, position])
|
||||
}
|
||||
|
||||
/// کد یکبارمصرف ورود پرتال مشتری (موبایل)
|
||||
model PortalOtp {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
mobile String
|
||||
code String
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, mobile, createdAt])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
ListActivitiesQueryDto,
|
||||
UpdateActivityDto,
|
||||
} from './dto';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivitiesController {
|
||||
constructor(private readonly activitiesService: ActivitiesService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.activitiesService.summary(user.organizationId, user);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: ListActivitiesQueryDto,
|
||||
) {
|
||||
return this.activitiesService.list(
|
||||
user.organizationId,
|
||||
user,
|
||||
query.filter ?? 'today',
|
||||
query.q,
|
||||
query.page ?? 1,
|
||||
query.limit ?? 50,
|
||||
{
|
||||
personId: query.personId,
|
||||
companyId: query.companyId,
|
||||
dealId: query.dealId,
|
||||
},
|
||||
{
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.activitiesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateActivityDto) {
|
||||
return this.activitiesService.create(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityDto,
|
||||
) {
|
||||
return this.activitiesService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
await this.activitiesService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [ActivitiesService],
|
||||
exports: [ActivitiesService],
|
||||
})
|
||||
export class ActivitiesModule {}
|
||||
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityFilter,
|
||||
ActivitySummaryDto,
|
||||
CreateActivityRequest,
|
||||
PaginatedResponse,
|
||||
UpdateActivityRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
type ActivityRow = {
|
||||
id: string;
|
||||
type: ActivityDto['type'];
|
||||
status: ActivityDto['status'];
|
||||
title: string;
|
||||
description: string | null;
|
||||
dueAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
assignee: { id: string; fullName: string } | null;
|
||||
};
|
||||
|
||||
const include = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
assignee: { select: { id: true, fullName: true } },
|
||||
} satisfies Prisma.ActivityInclude;
|
||||
|
||||
@Injectable()
|
||||
export class ActivitiesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async summary(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivitySummaryDto> {
|
||||
const { start, end } = dayBounds();
|
||||
const scope = assigneeWhere(actor);
|
||||
const [today, overdue, upcoming] = await Promise.all([
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }],
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
today: today.map((row) => this.toDto(row)),
|
||||
overdue: overdue.map((row) => this.toDto(row)),
|
||||
upcoming: upcoming.map((row) => this.toDto(row)),
|
||||
todayCount: today.length,
|
||||
overdueCount: overdue.length,
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
filter: ActivityFilter = 'today',
|
||||
q?: string,
|
||||
page = 1,
|
||||
limit = 50,
|
||||
entity?: {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
},
|
||||
): Promise<PaginatedResponse<ActivityDto>> {
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
actor,
|
||||
filter,
|
||||
q,
|
||||
entity,
|
||||
range,
|
||||
);
|
||||
const take = Math.min(Math.max(limit, 1), 200);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.activity.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * take,
|
||||
take,
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
};
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityDto> {
|
||||
const row = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
dto: CreateActivityRequest,
|
||||
actor?: ActivityActor,
|
||||
): Promise<ActivityDto> {
|
||||
const linkActor = actor ?? { userId, role: 'owner' };
|
||||
await this.assertLinks(organizationId, linkActor, dto);
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId;
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId,
|
||||
title: dto.title.trim(),
|
||||
type: dto.type ?? 'task',
|
||||
description: emptyToNull(dto.description),
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
assigneeId,
|
||||
},
|
||||
include,
|
||||
});
|
||||
|
||||
if (assigneeId) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: assigneeId,
|
||||
type: 'activity_assigned',
|
||||
title: 'فعالیت جدید برای شما',
|
||||
body: row.title,
|
||||
link: `/activities/${row.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
dto: UpdateActivityRequest,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.assertLinks(organizationId, actor, dto);
|
||||
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
if ('description' in dto) data.description = emptyToNull(dto.description);
|
||||
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('assigneeId' in dto) {
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
data.assigneeId = assigneeId;
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
data.completedAt =
|
||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
||||
}
|
||||
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data,
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async complete(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: { status: 'done', completedAt: new Date() },
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.prisma.activity.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private buildWhere(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
filter: ActivityFilter,
|
||||
q?: string,
|
||||
entity?: {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
},
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const base: Prisma.ActivityWhereInput = {
|
||||
organizationId,
|
||||
...assigneeWhere(actor),
|
||||
...(entity?.personId ? { personId: entity.personId } : {}),
|
||||
...(entity?.companyId ? { companyId: entity.companyId } : {}),
|
||||
...(entity?.dealId ? { dealId: entity.dealId } : {}),
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ description: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{
|
||||
person: {
|
||||
OR: [
|
||||
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ deal: { title: { contains: q.trim(), mode: 'insensitive' } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const fromDate = range?.from ? new Date(range.from) : null;
|
||||
const toDate = range?.to ? new Date(range.to) : null;
|
||||
if (fromDate || toDate) {
|
||||
return {
|
||||
...base,
|
||||
dueAt: {
|
||||
...(fromDate && !Number.isNaN(fromDate.getTime())
|
||||
? { gte: fromDate }
|
||||
: {}),
|
||||
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
case 'today':
|
||||
return { ...base, status: 'open', dueAt: { gte: start, lt: end } };
|
||||
case 'overdue':
|
||||
return { ...base, status: 'open', dueAt: { lt: start } };
|
||||
case 'upcoming':
|
||||
return {
|
||||
...base,
|
||||
status: 'open',
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
};
|
||||
case 'done':
|
||||
return { ...base, status: 'done' };
|
||||
case 'all':
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
) {
|
||||
const found = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
}
|
||||
|
||||
private async assertUserInOrg(organizationId: string, userId: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException('مسئول انتخابشده معتبر نیست');
|
||||
}
|
||||
|
||||
private async assertLinks(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
dto: {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
},
|
||||
) {
|
||||
const personId = emptyToNull(dto.personId ?? null);
|
||||
const companyId = emptyToNull(dto.companyId ?? null);
|
||||
const dealId = emptyToNull(dto.dealId ?? null);
|
||||
const scope = ownerWhere(actor);
|
||||
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('شخص انتخابشده معتبر نیست');
|
||||
}
|
||||
if (companyId) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت انتخابشده معتبر نیست');
|
||||
}
|
||||
if (dealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow): ActivityDto {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
||||
}
|
||||
: null,
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
assignee: row.assignee,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function dayBounds(now = new Date()) {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return { start, end };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
|
||||
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
|
||||
const ACTIVITY_FILTERS = ['today', 'overdue', 'upcoming', 'done', 'all'] as const;
|
||||
|
||||
export class ListActivitiesQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_FILTERS)
|
||||
filter?: (typeof ACTIVITY_FILTERS)[number] = 'today';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
/** Exclusive end (ISO). */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number = 50;
|
||||
}
|
||||
|
||||
export class CreateActivityDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { OrganizationsModule } from './organizations/organizations.module';
|
||||
import { ContactsModule } from './contacts/contacts.module';
|
||||
import { PipelinesModule } from './pipelines/pipelines.module';
|
||||
import { ActivitiesModule } from './activities/activities.module';
|
||||
import { NotesModule } from './notes/notes.module';
|
||||
import { CustomFieldsModule } from './custom-fields/custom-fields.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { LeadFormsModule } from './lead-forms/lead-forms.module';
|
||||
import { QuotesModule } from './quotes/quotes.module';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
import { SalesModule } from './sales/sales.module';
|
||||
import { TicketsModule } from './tickets/tickets.module';
|
||||
import { FaqModule } from './faq/faq.module';
|
||||
import { PortalModule } from './portal/portal.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env', '../../.env'],
|
||||
}),
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
OrganizationsModule,
|
||||
ContactsModule,
|
||||
PipelinesModule,
|
||||
ActivitiesModule,
|
||||
NotesModule,
|
||||
CustomFieldsModule,
|
||||
ProductsModule,
|
||||
NotificationsModule,
|
||||
LeadFormsModule,
|
||||
QuotesModule,
|
||||
InvoicesModule,
|
||||
SalesModule,
|
||||
TicketsModule,
|
||||
FaqModule,
|
||||
PortalModule,
|
||||
ReportsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, RegisterOrganizationDto } from './dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterOrganizationDto) {
|
||||
return this.authService.registerOrganization(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: { userId: string }) {
|
||||
return this.authService.me(user.userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET') ?? 'dev-secret',
|
||||
signOptions: {
|
||||
expiresIn: (config.get<string>('JWT_EXPIRES_IN') ?? '7d') as `${number}d`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, RolesGuard],
|
||||
exports: [AuthService, RolesGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import type { AuthUserDto, LoginResponse } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto, RegisterOrganizationDto } from './dto';
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u0600-\u06FF]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || `org-${Date.now()}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async registerOrganization(dto: RegisterOrganizationDto): Promise<LoginResponse> {
|
||||
const baseSlug = slugify(dto.organizationName);
|
||||
let slug = baseSlug;
|
||||
let i = 1;
|
||||
while (await this.prisma.organization.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${i++}`;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email.toLowerCase() },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const organization = await this.prisma.organization.create({
|
||||
data: {
|
||||
name: dto.organizationName,
|
||||
slug,
|
||||
users: {
|
||||
create: {
|
||||
email: dto.email.toLowerCase(),
|
||||
fullName: dto.fullName,
|
||||
passwordHash,
|
||||
role: 'owner',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { users: true },
|
||||
});
|
||||
|
||||
const user = organization.users[0];
|
||||
return this.buildLoginResponse(user);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<LoginResponse> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email.toLowerCase() },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
|
||||
}
|
||||
|
||||
return this.buildLoginResponse(user);
|
||||
}
|
||||
|
||||
async me(userId: string): Promise<AuthUserDto> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return this.toAuthUser(user);
|
||||
}
|
||||
|
||||
private buildLoginResponse(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: AuthUserDto['role'];
|
||||
organizationId: string;
|
||||
}): LoginResponse {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
organizationId: user.organizationId,
|
||||
role: user.role,
|
||||
};
|
||||
return {
|
||||
accessToken: this.jwt.sign(payload),
|
||||
user: this.toAuthUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
private toAuthUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: AuthUserDto['role'];
|
||||
organizationId: string;
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RegisterOrganizationDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
organizationName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
fullName!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
organizationId: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>('JWT_SECRET') ?? 'dev-secret',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
||||
if (payload.typ === 'portal') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return {
|
||||
userId: payload.sub,
|
||||
organizationId: payload.organizationId,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!roles?.length) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: { role?: string };
|
||||
}>();
|
||||
const role = request.user?.role as UserRole | undefined;
|
||||
if (!role || !roles.includes(role)) {
|
||||
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CURRENCY_CATALOG } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type RateMap = {
|
||||
baseCurrency: string;
|
||||
/** currency code → how many base units for 1 unit of that currency */
|
||||
rates: Map<string, number>;
|
||||
};
|
||||
|
||||
export function isKnownCurrency(code: string): boolean {
|
||||
return CURRENCY_CATALOG.some((c) => c.code === code);
|
||||
}
|
||||
|
||||
export async function loadOrgRates(
|
||||
prisma: PrismaService,
|
||||
organizationId: string,
|
||||
): Promise<RateMap> {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: {
|
||||
baseCurrency: true,
|
||||
exchangeRates: { select: { currency: true, rateToBase: true } },
|
||||
},
|
||||
});
|
||||
const baseCurrency = org?.baseCurrency || 'IRR';
|
||||
const rates = new Map<string, number>();
|
||||
rates.set(baseCurrency, 1);
|
||||
for (const row of org?.exchangeRates ?? []) {
|
||||
rates.set(row.currency, Number(row.rateToBase));
|
||||
}
|
||||
return { baseCurrency, rates };
|
||||
}
|
||||
|
||||
export function toBaseAmount(
|
||||
amount: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
map: RateMap,
|
||||
): number {
|
||||
if (amount == null || Number.isNaN(amount)) return 0;
|
||||
const code = (currency || map.baseCurrency).trim() || map.baseCurrency;
|
||||
if (code === map.baseCurrency) return amount;
|
||||
const rate = map.rates.get(code);
|
||||
if (rate == null || rate <= 0) return amount;
|
||||
return amount * rate;
|
||||
}
|
||||
|
||||
export function decimalToBase(
|
||||
amount: Prisma.Decimal | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
map: RateMap,
|
||||
): number {
|
||||
return toBaseAmount(amount == null ? 0 : Number(amount), currency, map);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export function asCustomFields(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function toJsonCustomFields(
|
||||
value?: Record<string, unknown> | null,
|
||||
): Prisma.InputJsonValue {
|
||||
return (value ?? {}) as Prisma.InputJsonValue;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import { canViewAllRecords } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
export type Actor = {
|
||||
userId: string;
|
||||
role: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export function elevated(role: string | null | undefined): boolean {
|
||||
return canViewAllRecords(role);
|
||||
}
|
||||
|
||||
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
|
||||
export function ownerWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
): { ownerId: string } | Record<string, never> {
|
||||
if (elevated(actor.role)) return {};
|
||||
return { ownerId: actor.userId };
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول */
|
||||
export function assigneeWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
): { assigneeId: string } | Record<string, never> {
|
||||
if (elevated(actor.role)) return {};
|
||||
return { assigneeId: actor.userId };
|
||||
}
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت */
|
||||
export function noteAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
): Prisma.NoteWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ authorId: actor.userId },
|
||||
{ deal: { ownerId: actor.userId } },
|
||||
{ person: { ownerId: actor.userId } },
|
||||
{ company: { ownerId: actor.userId } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOwnerId(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
requested?: string | null,
|
||||
): string {
|
||||
if (elevated(actor.role) && requested?.trim()) {
|
||||
return requested.trim();
|
||||
}
|
||||
return actor.userId;
|
||||
}
|
||||
|
||||
/** تیکت: مسئول یا ثبتکننده */
|
||||
export function ticketAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
): Prisma.TicketWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
return {
|
||||
OR: [{ assigneeId: actor.userId }, { reporterId: actor.userId }],
|
||||
};
|
||||
}
|
||||
|
||||
export function canReassignOwner(role: string | null | undefined): boolean {
|
||||
return elevated(role);
|
||||
}
|
||||
|
||||
export type OrgRole = UserRole;
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/** پنل ترِز / رایگان — همان الگوی montime */
|
||||
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
|
||||
const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx';
|
||||
|
||||
const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
|
||||
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
|
||||
|
||||
export type SmsSendResult = {
|
||||
ok: boolean;
|
||||
transport: 'trez' | 'soap' | 'link';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
const username = this.config.get<string>('SMS_USERNAME');
|
||||
const password = this.getPassword();
|
||||
const sender = this.config.get<string>('SMS_SENDER');
|
||||
return Boolean(username && password && sender);
|
||||
}
|
||||
|
||||
normalizePhoneForSms(mobile: string): string {
|
||||
const digits = String(mobile ?? '').replace(/\D/g, '');
|
||||
if (digits.startsWith('98') && digits.length === 12) {
|
||||
return `0${digits.slice(2)}`;
|
||||
}
|
||||
if (digits.startsWith('0') && digits.length === 11) {
|
||||
return digits;
|
||||
}
|
||||
if (digits.length === 10 && digits.startsWith('9')) {
|
||||
return `0${digits}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
||||
const template =
|
||||
this.config.get<string>('SMS_OTP_TEMPLATE') ??
|
||||
'کد ورود پرتال منتو: {code}';
|
||||
const text = template.replace('{code}', code);
|
||||
const result = await this.sendSmsDetailed(mobile, text);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
|
||||
const username = this.config.get<string>('SMS_USERNAME');
|
||||
const password = this.getPassword();
|
||||
const sender = this.config.get<string>('SMS_SENDER');
|
||||
|
||||
if (!username || !password || !sender) {
|
||||
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
|
||||
}
|
||||
|
||||
const to = this.normalizePhoneForSms(phone);
|
||||
const provider = this.resolveProvider();
|
||||
|
||||
if (provider === 'trez') {
|
||||
return this.sendViaTrez(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
const transport = this.resolve0098Transport();
|
||||
if (transport === 'link') {
|
||||
return this.sendVia0098Link(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
const soapResult = await this.sendVia0098Soap(
|
||||
username,
|
||||
password,
|
||||
sender,
|
||||
to,
|
||||
text,
|
||||
);
|
||||
if (soapResult.ok) return soapResult;
|
||||
this.logger.warn(
|
||||
`0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; link fallback`,
|
||||
);
|
||||
return this.sendVia0098Link(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
private resolveProvider(): 'trez' | '0098' {
|
||||
const raw = (this.config.get<string>('SMS_PROVIDER') ?? 'trez').toLowerCase();
|
||||
return raw === '0098' ? '0098' : 'trez';
|
||||
}
|
||||
|
||||
private resolve0098Transport(): 'soap' | 'link' {
|
||||
const raw = (this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase();
|
||||
return raw === 'link' ? 'link' : 'soap';
|
||||
}
|
||||
|
||||
private async sendViaTrez(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<SmsSendResult> {
|
||||
const baseUrl = (
|
||||
this.config.get<string>('SMS_BASE_URL') ?? SMS_TREZ_DEFAULT_BASE_URL
|
||||
).replace(/\/$/, '');
|
||||
const path =
|
||||
this.config.get<string>('SMS_SEND_PATH') ?? SMS_TREZ_DEFAULT_SEND_PATH;
|
||||
const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
PhoneNumber: sender,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Mobile: to,
|
||||
Message: text,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(this.readTimeoutMs()),
|
||||
});
|
||||
const responseBody = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
return { ok: false, transport: 'trez', reason: `HTTP ${res.status}` };
|
||||
}
|
||||
const parsed = this.parseTrezResponse(responseBody);
|
||||
if (!parsed.ok) {
|
||||
this.logger.warn(
|
||||
`Trez rejected to=${this.maskPhone(to)} reason=${parsed.reason}`,
|
||||
);
|
||||
}
|
||||
return { ...parsed, transport: 'trez' };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`Trez failed to=${this.maskPhone(to)}: ${message}`);
|
||||
return { ok: false, transport: 'trez', reason: 'Trez request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private parseTrezResponse(body: string): { ok: boolean; reason: string } {
|
||||
if (!body) return { ok: true, reason: 'accepted (empty response)' };
|
||||
const trimmed = body.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (
|
||||
lower.includes('error') ||
|
||||
lower.includes('fail') ||
|
||||
lower.includes('invalid') ||
|
||||
lower.includes('خطا')
|
||||
) {
|
||||
return { ok: false, reason: trimmed.slice(0, 120) };
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
const n = Number(trimmed);
|
||||
if (n === 1 || n > 1) return { ok: true, reason: 'accepted' };
|
||||
if (n === 0) return { ok: false, reason: 'provider code 0' };
|
||||
if (n < 0) return { ok: false, reason: `provider code ${trimmed}` };
|
||||
}
|
||||
if (lower === 'true' || lower === 'ok' || lower === 'success') {
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
if (lower === 'false') return { ok: false, reason: 'provider returned false' };
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
|
||||
private async sendVia0098Soap(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<SmsSendResult> {
|
||||
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<SendSMS xmlns="http://tempuri.org/">
|
||||
<username>${this.escapeXml(username)}</username>
|
||||
<password>${this.escapeXml(password)}</password>
|
||||
<text>${this.escapeXml(text)}</text>
|
||||
<mobileno>${this.escapeXml(to)}</mobileno>
|
||||
<pnlno>${this.escapeXml(sender)}</pnlno>
|
||||
</SendSMS>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
|
||||
try {
|
||||
const res = await fetch(SMS_0098_SOAP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: 'http://tempuri.org/SendSMS',
|
||||
},
|
||||
body: soapBody,
|
||||
signal: AbortSignal.timeout(this.readTimeoutMs()),
|
||||
});
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) return { ok: false, transport: 'soap', reason: `HTTP ${res.status}` };
|
||||
const code =
|
||||
body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i)?.[1]?.trim() ??
|
||||
null;
|
||||
const n = code != null ? Number(code) : NaN;
|
||||
if (Number.isFinite(n) && n >= 0) return { ok: true, transport: 'soap' };
|
||||
return {
|
||||
ok: false,
|
||||
transport: 'soap',
|
||||
reason: code ? `provider code ${code}` : 'unknown SOAP response',
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, transport: 'soap', reason: 'SOAP request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendVia0098Link(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<SmsSendResult> {
|
||||
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
|
||||
const params = new URLSearchParams({
|
||||
FROM: sender,
|
||||
TO: to,
|
||||
TEXT: text,
|
||||
USERNAME: username,
|
||||
PASSWORD: password,
|
||||
DOMAIN: domain,
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${SMS_0098_LINK_URL}?${params}`, {
|
||||
signal: AbortSignal.timeout(this.readTimeoutMs()),
|
||||
});
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) return { ok: false, transport: 'link', reason: `HTTP ${res.status}` };
|
||||
const lower = body.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('fail')) {
|
||||
return { ok: false, transport: 'link', reason: body.slice(0, 120) };
|
||||
}
|
||||
return { ok: true, transport: 'link' };
|
||||
} catch {
|
||||
return { ok: false, transport: 'link', reason: 'link request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private escapeXml(value: string): string {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private readTimeoutMs(): number {
|
||||
const raw = this.config.get<string>('SMS_TIMEOUT_MS');
|
||||
const n = raw ? Number.parseInt(raw, 10) : 20_000;
|
||||
return Number.isFinite(n) && n > 0 ? n : 20_000;
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length <= 6) return '***';
|
||||
return `${phone.slice(0, 4)}****${phone.slice(-2)}`;
|
||||
}
|
||||
|
||||
private getPassword(): string | undefined {
|
||||
return (
|
||||
this.config.get<string>('SMS_PASSWORD') ??
|
||||
this.config.get<string>('SMS_API_KEY')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto, ListQueryDto, UpdateCompanyDto } from './dto';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
|
||||
@Controller('companies')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly excelService: ContactsExcelService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
|
||||
return this.companiesService.list(
|
||||
user,
|
||||
query.q,
|
||||
query.page ?? 1,
|
||||
query.limit ?? 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('q') q: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const buffer = await this.excelService.exportCompanies(user, q);
|
||||
res.set({
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': 'attachment; filename="mento-companies.xlsx"',
|
||||
});
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.importCompanies(user, file);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.companiesService.get(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateCompanyDto) {
|
||||
return this.companiesService.create(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
) {
|
||||
return this.companiesService.update(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.companiesService.remove(user, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateCompanyRequest,
|
||||
PaginatedResponse,
|
||||
UpdateCompanyRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||
import {
|
||||
asCustomFields,
|
||||
toJsonCustomFields,
|
||||
} from '../common/custom-fields';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
elevated,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
|
||||
const companyInclude = {
|
||||
_count: { select: { people: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
} satisfies Prisma.CompanyInclude;
|
||||
|
||||
type CompanyRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
nationalId: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
customFields: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: { people: number };
|
||||
owner: { id: string; fullName: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<PaginatedResponse<CompanyDto>> {
|
||||
const where: Prisma.CompanyWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ email: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ phone: { contains: q.trim() } },
|
||||
{ nationalId: { contains: q.trim() } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.company.findMany({
|
||||
where,
|
||||
include: companyInclude,
|
||||
orderBy: [{ name: 'asc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.company.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async get(actor: Actor, id: string): Promise<CompanyDto> {
|
||||
const row = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
include: companyInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('شرکت پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(actor: Actor, dto: CreateCompanyRequest): Promise<CompanyDto> {
|
||||
const data: Prisma.CompanyUncheckedCreateInput = {
|
||||
organizationId: actor.organizationId,
|
||||
name: dto.name.trim(),
|
||||
nationalId: emptyToNull(dto.nationalId),
|
||||
phone: normalizePhone(dto.phone),
|
||||
email: normalizeEmail(dto.email),
|
||||
website: emptyToNull(dto.website),
|
||||
address: emptyToNull(dto.address),
|
||||
notes: emptyToNull(dto.notes),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
};
|
||||
await this.assertNoDuplicate(actor.organizationId, data);
|
||||
|
||||
const row = await this.prisma.company.create({
|
||||
data,
|
||||
include: companyInclude,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
dto: UpdateCompanyRequest,
|
||||
): Promise<CompanyDto> {
|
||||
await this.ensureExists(actor.organizationId, id, actor);
|
||||
const data: Prisma.CompanyUncheckedUpdateInput = {};
|
||||
if (dto.name !== undefined) data.name = dto.name.trim();
|
||||
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('address' in dto) data.address = emptyToNull(dto.address);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
const row = await this.prisma.company.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: companyInclude,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(actor: Actor, id: string): Promise<void> {
|
||||
await this.ensureExists(actor.organizationId, id, actor);
|
||||
await this.prisma.company.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async ensureInOrg(
|
||||
organizationId: string,
|
||||
companyId: string,
|
||||
actor?: Pick<Actor, 'userId' | 'role'>,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, companyId, actor);
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor?: Pick<Actor, 'userId' | 'role'>,
|
||||
) {
|
||||
const found = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
id,
|
||||
organizationId,
|
||||
...(actor && !elevated(actor.role)
|
||||
? { ownerId: actor.userId }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('شرکت پیدا نشد');
|
||||
}
|
||||
|
||||
private async assertNoDuplicate(
|
||||
organizationId: string,
|
||||
data: { email?: unknown; phone?: unknown; nationalId?: unknown },
|
||||
excludeId?: string,
|
||||
) {
|
||||
const email = typeof data.email === 'string' ? data.email : null;
|
||||
const phone = typeof data.phone === 'string' ? data.phone : null;
|
||||
const nationalId =
|
||||
typeof data.nationalId === 'string' ? data.nationalId : null;
|
||||
|
||||
const or: Prisma.CompanyWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (phone) or.push({ phone });
|
||||
if (nationalId) or.push({ nationalId });
|
||||
if (!or.length) return;
|
||||
|
||||
const clash = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: or,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
if (!clash) return;
|
||||
|
||||
if (email && clash.email === email) {
|
||||
throw new ConflictException('شرکتی با این ایمیل از قبل وجود دارد');
|
||||
}
|
||||
if (phone && clash.phone === phone) {
|
||||
throw new ConflictException('شرکتی با این تلفن از قبل وجود دارد');
|
||||
}
|
||||
if (nationalId && clash.nationalId === nationalId) {
|
||||
throw new ConflictException('شرکتی با این شناسه ملی از قبل وجود دارد');
|
||||
}
|
||||
throw new ConflictException('شرکت تکراری است');
|
||||
}
|
||||
|
||||
private toDto(row: CompanyRow): CompanyDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
nationalId: row.nationalId,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
website: row.website,
|
||||
address: row.address,
|
||||
notes: row.notes,
|
||||
customFields: asCustomFields(row.customFields),
|
||||
peopleCount: row._count.people,
|
||||
owner: row.owner,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleService } from './people.service';
|
||||
|
||||
function cellStr(value: ExcelJS.CellValue | undefined): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
|
||||
return value.text.trim();
|
||||
}
|
||||
if (typeof value === 'object' && 'result' in value && value.result != null) {
|
||||
return String(value.result).trim();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeHeader(raw: string) {
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
const PERSON_ALIASES: Record<string, string> = {
|
||||
firstname: 'firstName',
|
||||
نام: 'firstName',
|
||||
lastname: 'lastName',
|
||||
نامخانوادگی: 'lastName',
|
||||
'نام خانوادگی': 'lastName',
|
||||
email: 'email',
|
||||
ایمیل: 'email',
|
||||
phone: 'phone',
|
||||
تلفن: 'phone',
|
||||
mobile: 'mobile',
|
||||
موبایل: 'mobile',
|
||||
position: 'position',
|
||||
سمت: 'position',
|
||||
company: 'companyName',
|
||||
companyname: 'companyName',
|
||||
شرکت: 'companyName',
|
||||
notes: 'notes',
|
||||
یادداشت: 'notes',
|
||||
};
|
||||
|
||||
const COMPANY_ALIASES: Record<string, string> = {
|
||||
name: 'name',
|
||||
نام: 'name',
|
||||
نامشرکت: 'name',
|
||||
'نام شرکت': 'name',
|
||||
nationalid: 'nationalId',
|
||||
شناسهملی: 'nationalId',
|
||||
'شناسه ملی': 'nationalId',
|
||||
phone: 'phone',
|
||||
تلفن: 'phone',
|
||||
email: 'email',
|
||||
ایمیل: 'email',
|
||||
website: 'website',
|
||||
وبسایت: 'website',
|
||||
address: 'address',
|
||||
آدرس: 'address',
|
||||
notes: 'notes',
|
||||
یادداشت: 'notes',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ContactsExcelService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly people: PeopleService,
|
||||
private readonly companies: CompaniesService,
|
||||
) {}
|
||||
|
||||
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
||||
const { items } = await this.people.list(actor, q, 1, 5000);
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('اشخاص');
|
||||
sheet.columns = [
|
||||
{ header: 'نام', key: 'firstName', width: 16 },
|
||||
{ header: 'نام خانوادگی', key: 'lastName', width: 18 },
|
||||
{ header: 'موبایل', key: 'mobile', width: 16 },
|
||||
{ header: 'تلفن', key: 'phone', width: 16 },
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'سمت', key: 'position', width: 18 },
|
||||
{ header: 'شرکت', key: 'companyName', width: 24 },
|
||||
{ header: 'یادداشت', key: 'notes', width: 32 },
|
||||
];
|
||||
for (const p of items) {
|
||||
sheet.addRow({
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
mobile: p.mobile ?? '',
|
||||
phone: p.phone ?? '',
|
||||
email: p.email ?? '',
|
||||
position: p.position ?? '',
|
||||
companyName: p.company?.name ?? '',
|
||||
notes: p.notes ?? '',
|
||||
});
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
|
||||
const { items } = await this.companies.list(actor, q, 1, 5000);
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('شرکتها');
|
||||
sheet.columns = [
|
||||
{ header: 'نام شرکت', key: 'name', width: 28 },
|
||||
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
|
||||
{ header: 'تلفن', key: 'phone', width: 16 },
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'وبسایت', key: 'website', width: 24 },
|
||||
{ header: 'آدرس', key: 'address', width: 32 },
|
||||
{ header: 'یادداشت', key: 'notes', width: 32 },
|
||||
];
|
||||
for (const c of items) {
|
||||
sheet.addRow({
|
||||
name: c.name,
|
||||
nationalId: c.nationalId ?? '',
|
||||
phone: c.phone ?? '',
|
||||
email: c.email ?? '',
|
||||
website: c.website ?? '',
|
||||
address: c.address ?? '',
|
||||
notes: c.notes ?? '',
|
||||
});
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
async importPeople(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ImportContactsResult> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const rows = await this.readRows(file, PERSON_ALIASES);
|
||||
const result: ImportContactsResult = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
const firstName = row.firstName?.trim() ?? '';
|
||||
const lastName = row.lastName?.trim() ?? '';
|
||||
if (!firstName || !lastName) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: 'نام و نام خانوادگی الزامی است',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let companyId: string | null = null;
|
||||
const companyName = row.companyName?.trim();
|
||||
if (companyName) {
|
||||
companyId = await this.findOrCreateCompanyId(actor, companyName);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.people.create(actor, {
|
||||
firstName,
|
||||
lastName,
|
||||
email: row.email || undefined,
|
||||
phone: row.phone || undefined,
|
||||
mobile: row.mobile || undefined,
|
||||
position: row.position || undefined,
|
||||
notes: row.notes || undefined,
|
||||
companyId,
|
||||
});
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
if (e instanceof ConflictException) {
|
||||
result.skipped += 1;
|
||||
} else {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: e instanceof Error ? e.message : 'خطای ناشناخته',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async importCompanies(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ImportContactsResult> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const rows = await this.readRows(file, COMPANY_ALIASES);
|
||||
const result: ImportContactsResult = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
const name = row.name?.trim() ?? '';
|
||||
if (!name) {
|
||||
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.companies.create(actor, {
|
||||
name,
|
||||
nationalId: row.nationalId || undefined,
|
||||
phone: row.phone || undefined,
|
||||
email: row.email || undefined,
|
||||
website: row.website || undefined,
|
||||
address: row.address || undefined,
|
||||
notes: row.notes || undefined,
|
||||
});
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
if (e instanceof ConflictException) {
|
||||
result.skipped += 1;
|
||||
} else {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: e instanceof Error ? e.message : 'خطای ناشناخته',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findOrCreateCompanyId(
|
||||
actor: Actor,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const existing = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
name: { equals: name, mode: 'insensitive' },
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return existing.id;
|
||||
const created = await this.companies.create(actor, { name });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async readRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل اکسل خالی است');
|
||||
}
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
// exceljs accepts Node Buffer
|
||||
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
||||
const sheet = wb.worksheets[0];
|
||||
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
|
||||
|
||||
const headerRow = sheet.getRow(1);
|
||||
const map = new Map<number, string>();
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const key =
|
||||
aliases[normalizeHeader(raw)] ??
|
||||
aliases[raw.trim()] ??
|
||||
aliases[normalizeHeader(raw.replace(//g, ''))];
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم استفاده کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Record<string, string>[] = [];
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const [col, key] of map) {
|
||||
const value = cellStr(row.getCell(col).value);
|
||||
if (value) any = true;
|
||||
item[key] = value;
|
||||
}
|
||||
if (any) rows.push(item);
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException('هیچ ردیف دادهای در فایل نیست');
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
nationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
website?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateCompanyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
nationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
website?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
}
|
||||
|
||||
export class CreatePersonDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
lastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
mobile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
position?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
}
|
||||
|
||||
export class UpdatePersonDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
mobile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
position?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function emptyToNull(value?: string | null): string | null {␍
|
||||
if (value == null) return null;␍
|
||||
const trimmed = value.trim();␍
|
||||
return trimmed.length ? trimmed : null;␍
|
||||
}␍
|
||||
␍
|
||||
export function normalizeEmail(value?: string | null): string | null {␍
|
||||
const v = emptyToNull(value);␍
|
||||
return v ? v.toLowerCase() : null;␍
|
||||
}␍
|
||||
␍
|
||||
/** Keep digits and leading + for Iranian/international numbers. */␍
|
||||
export function normalizePhone(value?: string | null): string | null {␍
|
||||
const v = emptyToNull(value);␍
|
||||
if (!v) return null;␍
|
||||
const cleaned = v.replace(/[^\d+]/g, '');␍
|
||||
return cleaned.length ? cleaned : null;␍
|
||||
}␍
|
||||
␍
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
|
||||
@Controller('people')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PeopleController {
|
||||
constructor(
|
||||
private readonly peopleService: PeopleService,
|
||||
private readonly excelService: ContactsExcelService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
|
||||
return this.peopleService.list(
|
||||
user,
|
||||
query.q,
|
||||
query.page ?? 1,
|
||||
query.limit ?? 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('q') q: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const buffer = await this.excelService.exportPeople(user, q);
|
||||
res.set({
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': 'attachment; filename="mento-people.xlsx"',
|
||||
});
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.importPeople(user, file);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.peopleService.get(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
|
||||
return this.peopleService.create(user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePersonDto,
|
||||
) {
|
||||
return this.peopleService.update(user, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.peopleService.remove(user, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreatePersonRequest,
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
UpdatePersonRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||
import {
|
||||
asCustomFields,
|
||||
toJsonCustomFields,
|
||||
} from '../common/custom-fields';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
|
||||
const personInclude = {
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
} satisfies Prisma.PersonInclude;
|
||||
|
||||
type PersonRow = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
customFields: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
company: { id: string; name: string } | null;
|
||||
owner: { id: string; fullName: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PeopleService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly companies: CompaniesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<PaginatedResponse<PersonDto>> {
|
||||
const where: Prisma.PersonWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ email: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ mobile: { contains: q.trim() } },
|
||||
{ phone: { contains: q.trim() } },
|
||||
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.person.findMany({
|
||||
where,
|
||||
include: personInclude,
|
||||
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.person.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async get(actor: Actor, id: string): Promise<PersonDto> {
|
||||
const row = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
include: personInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('شخص پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
|
||||
const companyId = emptyToNull(dto.companyId ?? null);
|
||||
if (companyId) {
|
||||
await this.companies.ensureInOrg(actor.organizationId, companyId, actor);
|
||||
}
|
||||
|
||||
const data: Prisma.PersonUncheckedCreateInput = {
|
||||
organizationId: actor.organizationId,
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
position: emptyToNull(dto.position),
|
||||
notes: emptyToNull(dto.notes),
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
};
|
||||
await this.assertNoDuplicate(actor.organizationId, data);
|
||||
|
||||
const row = await this.prisma.person.create({
|
||||
data,
|
||||
include: personInclude,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
dto: UpdatePersonRequest,
|
||||
): Promise<PersonDto> {
|
||||
await this.ensureExists(actor, id);
|
||||
|
||||
const data: Prisma.PersonUncheckedUpdateInput = {};
|
||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if ('companyId' in dto) {
|
||||
const nextCompanyId = emptyToNull(dto.companyId ?? null);
|
||||
if (nextCompanyId) {
|
||||
await this.companies.ensureInOrg(
|
||||
actor.organizationId,
|
||||
nextCompanyId,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
data.companyId = nextCompanyId;
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
const row = await this.prisma.person.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: personInclude,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(actor: Actor, id: string): Promise<void> {
|
||||
await this.ensureExists(actor, id);
|
||||
await this.prisma.person.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureExists(actor: Actor, id: string) {
|
||||
const found = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
||||
}
|
||||
|
||||
private async assertNoDuplicate(
|
||||
organizationId: string,
|
||||
data: { email?: unknown; mobile?: unknown },
|
||||
excludeId?: string,
|
||||
) {
|
||||
const email = typeof data.email === 'string' ? data.email : null;
|
||||
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
if (!or.length) return;
|
||||
|
||||
const clash = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: or,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
if (!clash) return;
|
||||
|
||||
if (email && clash.email === email) {
|
||||
throw new ConflictException('شخصی با این ایمیل از قبل وجود دارد');
|
||||
}
|
||||
if (mobile && clash.mobile === mobile) {
|
||||
throw new ConflictException('شخصی با این موبایل از قبل وجود دارد');
|
||||
}
|
||||
throw new ConflictException('شخص تکراری است');
|
||||
}
|
||||
|
||||
private toDto(row: PersonRow): PersonDto {
|
||||
return {
|
||||
id: row.id,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
mobile: row.mobile,
|
||||
position: row.position,
|
||||
notes: row.notes,
|
||||
customFields: asCustomFields(row.customFields),
|
||||
company: row.company,
|
||||
owner: row.owner,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import {
|
||||
CreateCustomFieldDto,
|
||||
ListCustomFieldsQueryDto,
|
||||
UpdateCustomFieldDto,
|
||||
} from './dto';
|
||||
import { CustomFieldsService } from './custom-fields.service';
|
||||
|
||||
@Controller('custom-fields')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class CustomFieldsController {
|
||||
constructor(private readonly customFieldsService: CustomFieldsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListCustomFieldsQueryDto,
|
||||
) {
|
||||
return this.customFieldsService.list(user.organizationId, {
|
||||
entityType: query.entityType,
|
||||
activeOnly: query.activeOnly,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateCustomFieldDto,
|
||||
) {
|
||||
return this.customFieldsService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCustomFieldDto,
|
||||
) {
|
||||
return this.customFieldsService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.customFieldsService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CustomFieldsController } from './custom-fields.controller';
|
||||
import { CustomFieldsService } from './custom-fields.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CustomFieldsController],
|
||||
providers: [CustomFieldsService],
|
||||
exports: [CustomFieldsService],
|
||||
})
|
||||
export class CustomFieldsModule {}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateCustomFieldRequest,
|
||||
CustomFieldDefinitionDto,
|
||||
CustomFieldEntity,
|
||||
UpdateCustomFieldRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type DefinitionRow = {
|
||||
id: string;
|
||||
entityType: CustomFieldEntity;
|
||||
key: string;
|
||||
label: string;
|
||||
fieldType: CustomFieldDefinitionDto['fieldType'];
|
||||
options: string[];
|
||||
required: boolean;
|
||||
position: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CustomFieldsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts?: { entityType?: CustomFieldEntity; activeOnly?: boolean },
|
||||
): Promise<CustomFieldDefinitionDto[]> {
|
||||
const rows = await this.prisma.customFieldDefinition.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(opts?.entityType ? { entityType: opts.entityType } : {}),
|
||||
...(opts?.activeOnly ? { isActive: true } : {}),
|
||||
},
|
||||
orderBy: [{ entityType: 'asc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateCustomFieldRequest,
|
||||
): Promise<CustomFieldDefinitionDto> {
|
||||
const label = dto.label.trim();
|
||||
if (!label) throw new BadRequestException('عنوان فیلد الزامی است');
|
||||
|
||||
const key = this.normalizeKey(dto.key) || this.keyFromLabel(label);
|
||||
const fieldType = dto.fieldType ?? 'text';
|
||||
const options =
|
||||
fieldType === 'select'
|
||||
? (dto.options ?? []).map((o) => o.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (fieldType === 'select' && options.length === 0) {
|
||||
throw new BadRequestException('برای فیلد انتخابی حداقل یک گزینه لازم است');
|
||||
}
|
||||
|
||||
const maxPos = await this.prisma.customFieldDefinition.aggregate({
|
||||
where: { organizationId, entityType: dto.entityType },
|
||||
_max: { position: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const row = await this.prisma.customFieldDefinition.create({
|
||||
data: {
|
||||
organizationId,
|
||||
entityType: dto.entityType,
|
||||
key,
|
||||
label,
|
||||
fieldType,
|
||||
options,
|
||||
required: dto.required ?? false,
|
||||
position: dto.position ?? (maxPos._max.position ?? -1) + 1,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کلید این فیلد قبلاً برای همین موجودیت ثبت شده');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateCustomFieldRequest,
|
||||
): Promise<CustomFieldDefinitionDto> {
|
||||
const existing = await this.prisma.customFieldDefinition.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فیلد سفارشی پیدا نشد');
|
||||
|
||||
const data: Prisma.CustomFieldDefinitionUpdateInput = {};
|
||||
if (dto.label !== undefined) {
|
||||
const label = dto.label.trim();
|
||||
if (!label) throw new BadRequestException('عنوان فیلد الزامی است');
|
||||
data.label = label;
|
||||
}
|
||||
if (dto.fieldType !== undefined) data.fieldType = dto.fieldType;
|
||||
if (dto.options !== undefined) {
|
||||
data.options = dto.options.map((o) => o.trim()).filter(Boolean);
|
||||
}
|
||||
if (dto.required !== undefined) data.required = dto.required;
|
||||
if (dto.position !== undefined) data.position = dto.position;
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
|
||||
const nextType = dto.fieldType ?? existing.fieldType;
|
||||
const nextOptions =
|
||||
dto.options !== undefined
|
||||
? dto.options.map((o) => o.trim()).filter(Boolean)
|
||||
: existing.options;
|
||||
if (nextType === 'select' && nextOptions.length === 0) {
|
||||
throw new BadRequestException('برای فیلد انتخابی حداقل یک گزینه لازم است');
|
||||
}
|
||||
if (nextType !== 'select') {
|
||||
data.options = [];
|
||||
}
|
||||
|
||||
const row = await this.prisma.customFieldDefinition.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const existing = await this.prisma.customFieldDefinition.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فیلد سفارشی پیدا نشد');
|
||||
await this.prisma.customFieldDefinition.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private normalizeKey(raw?: string) {
|
||||
if (!raw?.trim()) return '';
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
private keyFromLabel(label: string) {
|
||||
const latin = this.normalizeKey(label);
|
||||
if (latin) return latin.slice(0, 48);
|
||||
return `cf_${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
private toDto(row: DefinitionRow): CustomFieldDefinitionDto {
|
||||
return {
|
||||
id: row.id,
|
||||
entityType: row.entityType,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
fieldType: row.fieldType,
|
||||
options: row.options,
|
||||
required: row.required,
|
||||
position: row.position,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum CustomFieldEntityDto {
|
||||
person = 'person',
|
||||
company = 'company',
|
||||
deal = 'deal',
|
||||
}
|
||||
|
||||
export enum CustomFieldTypeDto {
|
||||
text = 'text',
|
||||
number = 'number',
|
||||
date = 'date',
|
||||
boolean = 'boolean',
|
||||
select = 'select',
|
||||
}
|
||||
|
||||
export class ListCustomFieldsQueryDto {
|
||||
@IsOptional()
|
||||
@IsEnum(CustomFieldEntityDto)
|
||||
entityType?: CustomFieldEntityDto;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
activeOnly?: boolean;
|
||||
}
|
||||
|
||||
export class CreateCustomFieldDto {
|
||||
@IsEnum(CustomFieldEntityDto)
|
||||
entityType!: CustomFieldEntityDto;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
key?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CustomFieldTypeDto)
|
||||
fieldType?: CustomFieldTypeDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
options?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateCustomFieldDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CustomFieldTypeDto)
|
||||
fieldType?: CustomFieldTypeDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
options?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
export class CreateFaqArticleDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
question!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(8000)
|
||||
answer!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPublished?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class UpdateFaqArticleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
question?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(8000)
|
||||
answer?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPublished?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class ListFaqQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
/** اگر true فقط مقالات منتشرشده */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (value === true || value === 'true' || value === '1') return true;
|
||||
if (value === false || value === 'false' || value === '0') return false;
|
||||
return Boolean(value);
|
||||
})
|
||||
@IsBoolean()
|
||||
publishedOnly?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import {
|
||||
CreateFaqArticleDto,
|
||||
ListFaqQueryDto,
|
||||
UpdateFaqArticleDto,
|
||||
} from './dto';
|
||||
import { FaqService } from './faq.service';
|
||||
|
||||
@Controller('faq')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class FaqController {
|
||||
constructor(private readonly faqService: FaqService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string; role: string },
|
||||
@Query() query: ListFaqQueryDto,
|
||||
) {
|
||||
const canManage = user.role === 'owner' || user.role === 'admin';
|
||||
return this.faqService.list(user.organizationId, {
|
||||
q: query.q,
|
||||
category: query.category,
|
||||
publishedOnly: query.publishedOnly ?? !canManage,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.faqService.get(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateFaqArticleDto,
|
||||
) {
|
||||
return this.faqService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFaqArticleDto,
|
||||
) {
|
||||
return this.faqService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.faqService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FaqController } from './faq.controller';
|
||||
import { FaqService } from './faq.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [FaqController],
|
||||
providers: [FaqService],
|
||||
exports: [FaqService],
|
||||
})
|
||||
export class FaqModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
CreateFaqArticleRequest,
|
||||
FaqArticleDto,
|
||||
UpdateFaqArticleRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class FaqService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts: { q?: string; category?: string; publishedOnly?: boolean },
|
||||
): Promise<FaqArticleDto[]> {
|
||||
const where: Prisma.FaqArticleWhereInput = {
|
||||
organizationId,
|
||||
...(opts.publishedOnly ? { isPublished: true } : {}),
|
||||
...(opts.category?.trim()
|
||||
? { category: opts.category.trim() }
|
||||
: {}),
|
||||
...(opts.q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ question: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ answer: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const rows = await this.prisma.faqArticle.findMany({
|
||||
where,
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map(toDto);
|
||||
}
|
||||
|
||||
async get(organizationId: string, id: string): Promise<FaqArticleDto> {
|
||||
const row = await this.prisma.faqArticle.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('مقاله FAQ پیدا نشد');
|
||||
return toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateFaqArticleRequest,
|
||||
): Promise<FaqArticleDto> {
|
||||
const row = await this.prisma.faqArticle.create({
|
||||
data: {
|
||||
organizationId,
|
||||
question: dto.question.trim(),
|
||||
answer: dto.answer.trim(),
|
||||
category: emptyToNull(dto.category ?? null),
|
||||
isPublished: dto.isPublished ?? true,
|
||||
position: dto.position ?? 0,
|
||||
},
|
||||
});
|
||||
return toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateFaqArticleRequest,
|
||||
): Promise<FaqArticleDto> {
|
||||
await this.ensureExists(organizationId, id);
|
||||
const data: Prisma.FaqArticleUpdateInput = {};
|
||||
if (dto.question !== undefined) data.question = dto.question.trim();
|
||||
if (dto.answer !== undefined) data.answer = dto.answer.trim();
|
||||
if ('category' in dto) data.category = emptyToNull(dto.category ?? null);
|
||||
if (dto.isPublished !== undefined) data.isPublished = dto.isPublished;
|
||||
if (dto.position !== undefined) data.position = dto.position;
|
||||
const row = await this.prisma.faqArticle.update({ where: { id }, data });
|
||||
return toDto(row);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.ensureExists(organizationId, id);
|
||||
await this.prisma.faqArticle.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureExists(organizationId: string, id: string) {
|
||||
const found = await this.prisma.faqArticle.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('مقاله FAQ پیدا نشد');
|
||||
}
|
||||
}
|
||||
|
||||
function toDto(row: {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
category: string | null;
|
||||
isPublished: boolean;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): FaqArticleDto {
|
||||
return {
|
||||
id: row.id,
|
||||
question: row.question,
|
||||
answer: row.answer,
|
||||
category: row.category,
|
||||
isPublished: row.isPublished,
|
||||
position: row.position,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import type { HealthResponse } from '@mento/shared';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
getHealth(): HealthResponse {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'mento-api',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const INVOICE_STATUSES = [
|
||||
'draft',
|
||||
'issued',
|
||||
'partially_paid',
|
||||
'paid',
|
||||
'canceled',
|
||||
] as const;
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
'cash',
|
||||
'transfer',
|
||||
'card',
|
||||
'check',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
export class InvoiceLineItemInputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
quantity!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class InvoiceInstallmentInputDto {
|
||||
@IsString()
|
||||
dueDate!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVOICE_STATUSES)
|
||||
status?: (typeof INVOICE_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
issuedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
quoteId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fromDealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fromQuoteId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceLineItemInputDto)
|
||||
@ArrayMinSize(0)
|
||||
lineItems?: InvoiceLineItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceInstallmentInputDto)
|
||||
installments?: InvoiceInstallmentInputDto[];
|
||||
}
|
||||
|
||||
export class UpdateInvoiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVOICE_STATUSES)
|
||||
status?: (typeof INVOICE_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
notes?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
issuedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
quoteId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceLineItemInputDto)
|
||||
lineItems?: InvoiceLineItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoiceInstallmentInputDto)
|
||||
installments?: InvoiceInstallmentInputDto[];
|
||||
}
|
||||
|
||||
export class CreatePaymentDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PAYMENT_METHODS)
|
||||
method?: (typeof PAYMENT_METHODS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
paidAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
installmentId?: string | null;
|
||||
}
|
||||
|
||||
export class ListInvoicesQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVOICE_STATUSES)
|
||||
status?: (typeof INVOICE_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
quoteId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
CreatePaymentDto,
|
||||
ListInvoicesQueryDto,
|
||||
UpdateInvoiceDto,
|
||||
} from './dto';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('invoices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InvoicesController {
|
||||
constructor(private readonly invoicesService: InvoicesService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: ListInvoicesQueryDto,
|
||||
) {
|
||||
return this.invoicesService.list(user.organizationId, user, {
|
||||
q: query.q,
|
||||
status: query.status,
|
||||
dealId: query.dealId,
|
||||
personId: query.personId,
|
||||
quoteId: query.quoteId,
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.invoicesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateInvoiceDto) {
|
||||
return this.invoicesService.create(user.organizationId, user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateInvoiceDto,
|
||||
) {
|
||||
return this.invoicesService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
await this.invoicesService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(':id/payments')
|
||||
addPayment(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreatePaymentDto,
|
||||
) {
|
||||
return this.invoicesService.addPayment(
|
||||
user.organizationId,
|
||||
id,
|
||||
user,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id/payments/:paymentId')
|
||||
removePayment(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('paymentId') paymentId: string,
|
||||
) {
|
||||
return this.invoicesService.removePayment(
|
||||
user.organizationId,
|
||||
id,
|
||||
paymentId,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
@@ -0,0 +1,641 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateInvoiceRequest,
|
||||
CreatePaymentRequest,
|
||||
DealLineItemInput,
|
||||
InvoiceDto,
|
||||
InvoiceInstallmentInput,
|
||||
PaginatedResponse,
|
||||
PaymentDto,
|
||||
UpdateInvoiceRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
|
||||
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
const include = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
quote: { select: { id: true, number: true, title: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
lineItems: { orderBy: { position: 'asc' as const } },
|
||||
payments: { orderBy: { paidAt: 'desc' as const } },
|
||||
installments: {
|
||||
orderBy: { position: 'asc' as const },
|
||||
include: { payment: { select: { id: true } } },
|
||||
},
|
||||
} satisfies Prisma.InvoiceInclude;
|
||||
|
||||
type InvoiceRow = Prisma.InvoiceGetPayload<{ include: typeof include }>;
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
actor: InvoiceActor,
|
||||
opts: {
|
||||
q?: string;
|
||||
status?: string;
|
||||
dealId?: string;
|
||||
personId?: string;
|
||||
quoteId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<PaginatedResponse<InvoiceDto>> {
|
||||
const page = opts.page ?? 1;
|
||||
const limit = opts.limit ?? 50;
|
||||
const where: Prisma.InvoiceWhereInput = {
|
||||
organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(opts.status ? { status: opts.status as never } : {}),
|
||||
...(opts.dealId ? { dealId: opts.dealId } : {}),
|
||||
...(opts.personId ? { personId: opts.personId } : {}),
|
||||
...(opts.quoteId ? { quoteId: opts.quoteId } : {}),
|
||||
...(opts.q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ notes: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.invoice.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.invoice.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
): Promise<InvoiceDto> {
|
||||
const row = await this.prisma.invoice.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
dto: CreateInvoiceRequest,
|
||||
): Promise<InvoiceDto> {
|
||||
let title = dto.title.trim();
|
||||
let personId = emptyToNull(dto.personId ?? null);
|
||||
let companyId = emptyToNull(dto.companyId ?? null);
|
||||
let dealId = emptyToNull(dto.dealId ?? null);
|
||||
let quoteId = emptyToNull(dto.quoteId ?? null);
|
||||
let lineItems = dto.lineItems ?? [];
|
||||
let currency = (dto.currency ?? 'IRR').trim() || 'IRR';
|
||||
let notes = emptyToNull(dto.notes ?? null);
|
||||
let dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
let issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
|
||||
let status = (dto.status as never) || 'draft';
|
||||
|
||||
const fromQuoteId = emptyToNull(dto.fromQuoteId ?? null);
|
||||
if (fromQuoteId) {
|
||||
const quote = await this.prisma.quote.findFirst({
|
||||
where: { id: fromQuoteId, organizationId },
|
||||
include: { lineItems: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!quote) throw new BadRequestException('پیشفاکتور مبدأ معتبر نیست');
|
||||
quoteId = quote.id;
|
||||
dealId = dealId ?? quote.dealId;
|
||||
personId = personId ?? quote.personId;
|
||||
companyId = companyId ?? quote.companyId;
|
||||
if (!title || title === 'فاکتور') {
|
||||
title = `فاکتور — ${quote.title}`;
|
||||
}
|
||||
currency = quote.currency || currency;
|
||||
notes = notes ?? quote.notes;
|
||||
if (!lineItems.length && quote.lineItems.length) {
|
||||
lineItems = quote.lineItems.map((item) => ({
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unitPrice),
|
||||
currency: item.currency,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const fromDealId = emptyToNull(dto.fromDealId ?? null);
|
||||
if (fromDealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: fromDealId, organizationId },
|
||||
include: { lineItems: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله مبدأ معتبر نیست');
|
||||
dealId = deal.id;
|
||||
personId = personId ?? deal.personId;
|
||||
companyId = companyId ?? deal.companyId;
|
||||
if (!title || title === 'فاکتور') {
|
||||
title = `فاکتور — ${deal.title}`;
|
||||
}
|
||||
currency = deal.currency || currency;
|
||||
notes = notes ?? deal.notes;
|
||||
if (!lineItems.length && deal.lineItems.length) {
|
||||
lineItems = deal.lineItems.map((item) => ({
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity: Number(item.quantity),
|
||||
unitPrice: Number(item.unitPrice),
|
||||
currency: item.currency,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'draft' && !issuedAt) {
|
||||
issuedAt = new Date();
|
||||
}
|
||||
|
||||
await this.assertLinks(
|
||||
organizationId,
|
||||
personId,
|
||||
companyId,
|
||||
dealId,
|
||||
quoteId,
|
||||
);
|
||||
if (lineItems.length) {
|
||||
await this.assertProducts(organizationId, lineItems);
|
||||
}
|
||||
|
||||
const number = await this.nextNumber(organizationId);
|
||||
const installments = dto.installments ?? [];
|
||||
|
||||
const row = await this.prisma.invoice.create({
|
||||
data: {
|
||||
organizationId,
|
||||
number,
|
||||
title,
|
||||
status,
|
||||
currency,
|
||||
notes,
|
||||
issuedAt,
|
||||
dueDate,
|
||||
personId,
|
||||
companyId,
|
||||
dealId,
|
||||
quoteId,
|
||||
ownerId: userId,
|
||||
...(lineItems.length
|
||||
? {
|
||||
lineItems: {
|
||||
create: lineItems.map((item, index) =>
|
||||
this.lineCreate(item, index, currency),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(installments.length
|
||||
? {
|
||||
installments: {
|
||||
create: installments.map((item, index) =>
|
||||
this.installmentCreate(item, index, currency),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
dto: UpdateInvoiceRequest,
|
||||
): Promise<InvoiceDto> {
|
||||
const existing = await this.prisma.invoice.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true, status: true, currency: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
|
||||
if (
|
||||
'personId' in dto ||
|
||||
'companyId' in dto ||
|
||||
'dealId' in dto ||
|
||||
'quoteId' in dto
|
||||
) {
|
||||
await this.assertLinks(
|
||||
organizationId,
|
||||
emptyToNull(dto.personId ?? null),
|
||||
emptyToNull(dto.companyId ?? null),
|
||||
emptyToNull(dto.dealId ?? null),
|
||||
emptyToNull(dto.quoteId ?? null),
|
||||
);
|
||||
}
|
||||
if (dto.lineItems?.length) {
|
||||
await this.assertProducts(organizationId, dto.lineItems);
|
||||
}
|
||||
|
||||
const data: Prisma.InvoiceUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if (dto.status !== undefined) data.status = dto.status as never;
|
||||
if (dto.currency !== undefined) {
|
||||
data.currency = dto.currency.trim() || 'IRR';
|
||||
}
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
|
||||
if ('issuedAt' in dto) {
|
||||
data.issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
|
||||
}
|
||||
if ('dueDate' in dto) {
|
||||
data.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
}
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('quoteId' in dto) data.quoteId = emptyToNull(dto.quoteId ?? null);
|
||||
|
||||
if (
|
||||
dto.status &&
|
||||
dto.status !== 'draft' &&
|
||||
dto.status !== 'canceled' &&
|
||||
!('issuedAt' in dto)
|
||||
) {
|
||||
data.issuedAt = new Date();
|
||||
}
|
||||
|
||||
const currency =
|
||||
(typeof data.currency === 'string' && data.currency) ||
|
||||
existing.currency ||
|
||||
'IRR';
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.lineItems) {
|
||||
await tx.invoiceLineItem.deleteMany({ where: { invoiceId: id } });
|
||||
data.lineItems = {
|
||||
create: dto.lineItems.map((item, index) =>
|
||||
this.lineCreate(item, index, currency),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.installments) {
|
||||
const linkedPayments = await tx.payment.count({
|
||||
where: { invoiceId: id, installmentId: { not: null } },
|
||||
});
|
||||
if (linkedPayments > 0) {
|
||||
throw new BadRequestException(
|
||||
'برای تغییر اقساط، ابتدا پرداختهای متصل به قسط را حذف کنید',
|
||||
);
|
||||
}
|
||||
await tx.invoiceInstallment.deleteMany({ where: { invoiceId: id } });
|
||||
data.installments = {
|
||||
create: dto.installments.map((item, index) =>
|
||||
this.installmentCreate(item, index, currency),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(data).length) {
|
||||
await tx.invoice.update({ where: { id }, data });
|
||||
}
|
||||
});
|
||||
|
||||
if (dto.status === undefined || dto.status === 'issued') {
|
||||
await this.recomputeStatus(id);
|
||||
}
|
||||
|
||||
return this.get(organizationId, id, actor);
|
||||
}
|
||||
|
||||
async remove(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.prisma.invoice.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addPayment(
|
||||
organizationId: string,
|
||||
invoiceId: string,
|
||||
actor: InvoiceActor,
|
||||
dto: CreatePaymentRequest,
|
||||
): Promise<InvoiceDto> {
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: invoiceId, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true, status: true, currency: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
if (invoice.status === 'canceled') {
|
||||
throw new BadRequestException('روی فاکتور لغوشده نمیتوان پرداخت ثبت کرد');
|
||||
}
|
||||
|
||||
const installmentId = emptyToNull(dto.installmentId ?? null);
|
||||
if (installmentId) {
|
||||
const installment = await this.prisma.invoiceInstallment.findFirst({
|
||||
where: { id: installmentId, invoiceId },
|
||||
include: { payment: true },
|
||||
});
|
||||
if (!installment) throw new BadRequestException('قسط معتبر نیست');
|
||||
if (installment.payment) {
|
||||
throw new BadRequestException('برای این قسط قبلاً پرداخت ثبت شده');
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.payment.create({
|
||||
data: {
|
||||
invoiceId,
|
||||
amount: new Prisma.Decimal(dto.amount),
|
||||
currency: (dto.currency ?? invoice.currency).trim() || 'IRR',
|
||||
method: (dto.method as never) || 'transfer',
|
||||
paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
|
||||
notes: emptyToNull(dto.notes ?? null),
|
||||
installmentId,
|
||||
},
|
||||
});
|
||||
if (installmentId) {
|
||||
await tx.invoiceInstallment.update({
|
||||
where: { id: installmentId },
|
||||
data: {
|
||||
status: 'paid',
|
||||
paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (invoice.status === 'draft') {
|
||||
await tx.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { status: 'issued', issuedAt: new Date() },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.recomputeStatus(invoiceId);
|
||||
return this.get(organizationId, invoiceId, actor);
|
||||
}
|
||||
|
||||
async removePayment(
|
||||
organizationId: string,
|
||||
invoiceId: string,
|
||||
paymentId: string,
|
||||
actor: InvoiceActor,
|
||||
): Promise<InvoiceDto> {
|
||||
await this.ensureExists(organizationId, invoiceId, actor);
|
||||
const payment = await this.prisma.payment.findFirst({
|
||||
where: { id: paymentId, invoiceId },
|
||||
});
|
||||
if (!payment) throw new NotFoundException('پرداخت پیدا نشد');
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (payment.installmentId) {
|
||||
await tx.invoiceInstallment.update({
|
||||
where: { id: payment.installmentId },
|
||||
data: { status: 'pending', paidAt: null },
|
||||
});
|
||||
}
|
||||
await tx.payment.delete({ where: { id: paymentId } });
|
||||
});
|
||||
|
||||
await this.recomputeStatus(invoiceId);
|
||||
return this.get(organizationId, invoiceId, actor);
|
||||
}
|
||||
|
||||
private async recomputeStatus(invoiceId: string) {
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: invoiceId },
|
||||
include: {
|
||||
lineItems: true,
|
||||
payments: true,
|
||||
},
|
||||
});
|
||||
if (!invoice || invoice.status === 'canceled' || invoice.status === 'draft') {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = invoice.lineItems.reduce(
|
||||
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
|
||||
0,
|
||||
);
|
||||
const paid = invoice.payments.reduce(
|
||||
(sum, item) => sum + Number(item.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
let status: 'issued' | 'partially_paid' | 'paid' = 'issued';
|
||||
if (paid <= 0) status = 'issued';
|
||||
else if (paid + 0.0001 >= total && total > 0) status = 'paid';
|
||||
else if (paid > 0) status = 'partially_paid';
|
||||
else status = 'issued';
|
||||
|
||||
if (invoice.status !== status) {
|
||||
await this.prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private lineCreate(
|
||||
item: DealLineItemInput,
|
||||
index: number,
|
||||
currency: string,
|
||||
) {
|
||||
return {
|
||||
productId: emptyToNull(item.productId ?? null),
|
||||
name: item.name.trim(),
|
||||
quantity: new Prisma.Decimal(item.quantity),
|
||||
unitPrice: new Prisma.Decimal(item.unitPrice),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
position: index,
|
||||
};
|
||||
}
|
||||
|
||||
private installmentCreate(
|
||||
item: InvoiceInstallmentInput,
|
||||
index: number,
|
||||
currency: string,
|
||||
) {
|
||||
return {
|
||||
position: index,
|
||||
dueDate: new Date(item.dueDate),
|
||||
amount: new Prisma.Decimal(item.amount),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
status: 'pending' as const,
|
||||
};
|
||||
}
|
||||
|
||||
private async nextNumber(organizationId: string): Promise<string> {
|
||||
const count = await this.prisma.invoice.count({ where: { organizationId } });
|
||||
return `FA-${String(count + 1).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: InvoiceActor,
|
||||
) {
|
||||
const found = await this.prisma.invoice.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('فاکتور پیدا نشد');
|
||||
}
|
||||
|
||||
private async assertLinks(
|
||||
organizationId: string,
|
||||
personId: string | null,
|
||||
companyId: string | null,
|
||||
dealId: string | null,
|
||||
quoteId: string | null,
|
||||
) {
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('شخص معتبر نیست');
|
||||
}
|
||||
if (companyId) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت معتبر نیست');
|
||||
}
|
||||
if (dealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله معتبر نیست');
|
||||
}
|
||||
if (quoteId) {
|
||||
const quote = await this.prisma.quote.findFirst({
|
||||
where: { id: quoteId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!quote) throw new BadRequestException('پیشفاکتور معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertProducts(
|
||||
organizationId: string,
|
||||
lines: DealLineItemInput[],
|
||||
) {
|
||||
const ids = [
|
||||
...new Set(
|
||||
lines
|
||||
.map((l) => emptyToNull(l.productId ?? null))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
if (!ids.length) return;
|
||||
const count = await this.prisma.product.count({
|
||||
where: { organizationId, id: { in: ids } },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('یکی از محصولات معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: InvoiceRow): InvoiceDto {
|
||||
const lineItems = row.lineItems.map((item) => {
|
||||
const quantity = Number(item.quantity);
|
||||
const unitPrice = Number(item.unitPrice);
|
||||
return {
|
||||
id: item.id,
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity,
|
||||
unitPrice,
|
||||
currency: item.currency,
|
||||
position: item.position,
|
||||
lineTotal: quantity * unitPrice,
|
||||
};
|
||||
});
|
||||
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
const payments: PaymentDto[] = row.payments.map((p) => ({
|
||||
id: p.id,
|
||||
amount: Number(p.amount),
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
paidAt: p.paidAt.toISOString(),
|
||||
notes: p.notes,
|
||||
installmentId: p.installmentId,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
}));
|
||||
const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const installments = row.installments.map((item) => ({
|
||||
id: item.id,
|
||||
position: item.position,
|
||||
dueDate: item.dueDate.toISOString(),
|
||||
amount: Number(item.amount),
|
||||
currency: item.currency,
|
||||
status: item.status,
|
||||
paidAt: item.paidAt?.toISOString() ?? null,
|
||||
paymentId: item.payment?.id ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
currency: row.currency,
|
||||
notes: row.notes,
|
||||
issuedAt: row.issuedAt?.toISOString() ?? null,
|
||||
dueDate: row.dueDate?.toISOString() ?? null,
|
||||
totalAmount,
|
||||
paidAmount,
|
||||
remainingAmount: Math.max(0, totalAmount - paidAmount),
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
||||
}
|
||||
: null,
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
quote: row.quote,
|
||||
owner: row.owner,
|
||||
lineItems,
|
||||
payments,
|
||||
installments,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const FIELD_TYPES = ['text', 'email', 'phone', 'textarea'] as const;
|
||||
const MAP_TO = [
|
||||
'person.firstName',
|
||||
'person.lastName',
|
||||
'person.email',
|
||||
'person.mobile',
|
||||
'person.phone',
|
||||
'person.position',
|
||||
'deal.title',
|
||||
'deal.notes',
|
||||
'notes',
|
||||
] as const;
|
||||
|
||||
export class LeadFormFieldInputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
key?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
label!: string;
|
||||
|
||||
@IsIn(FIELD_TYPES)
|
||||
fieldType!: (typeof FIELD_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
|
||||
@IsIn(MAP_TO)
|
||||
mapTo!: (typeof MAP_TO)[number];
|
||||
|
||||
@IsOptional()
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class CreateLeadFormDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createPerson?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createDeal?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pipelineId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
source?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
thankYouMessage?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
redirectUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(50)
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LeadFormFieldInputDto)
|
||||
@ArrayMaxSize(30)
|
||||
fields?: LeadFormFieldInputDto[];
|
||||
}
|
||||
|
||||
export class UpdateLeadFormDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createPerson?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createDeal?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pipelineId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
source?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
thankYouMessage?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
redirectUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(50)
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LeadFormFieldInputDto)
|
||||
@ArrayMaxSize(30)
|
||||
fields?: LeadFormFieldInputDto[];
|
||||
}
|
||||
|
||||
export class SubmitLeadFormDto {
|
||||
@IsObject()
|
||||
values!: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateLeadFormDto, UpdateLeadFormDto } from './dto';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
|
||||
@Controller('lead-forms')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class LeadFormsController {
|
||||
constructor(private readonly leadFormsService: LeadFormsService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('owner', 'admin')
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.leadFormsService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('owner', 'admin')
|
||||
get(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.leadFormsService.get(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateLeadFormDto,
|
||||
) {
|
||||
return this.leadFormsService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLeadFormDto,
|
||||
) {
|
||||
return this.leadFormsService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.leadFormsService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LeadFormsController } from './lead-forms.controller';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
import { PublicLeadFormsController } from './public-lead-forms.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [LeadFormsController, PublicLeadFormsController],
|
||||
providers: [LeadFormsService],
|
||||
exports: [LeadFormsService],
|
||||
})
|
||||
export class LeadFormsModule {}
|
||||
@@ -0,0 +1,525 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateLeadFormRequest,
|
||||
LeadFormDto,
|
||||
LeadFormFieldInput,
|
||||
PublicLeadFormDto,
|
||||
SubmitLeadFormResponse,
|
||||
UpdateLeadFormRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
normalizePhone,
|
||||
} from '../contacts/normalize';
|
||||
|
||||
const DEFAULT_FIELDS: LeadFormFieldInput[] = [
|
||||
{
|
||||
key: 'firstName',
|
||||
label: 'نام',
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
mapTo: 'person.firstName',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
key: 'lastName',
|
||||
label: 'نام خانوادگی',
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
mapTo: 'person.lastName',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
label: 'موبایل',
|
||||
fieldType: 'phone',
|
||||
required: true,
|
||||
mapTo: 'person.mobile',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'ایمیل',
|
||||
fieldType: 'email',
|
||||
required: false,
|
||||
mapTo: 'person.email',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
label: 'پیام',
|
||||
fieldType: 'textarea',
|
||||
required: false,
|
||||
mapTo: 'deal.notes',
|
||||
position: 4,
|
||||
},
|
||||
];
|
||||
|
||||
type FormRow = Prisma.LeadFormGetPayload<{ include: { fields: true } }>;
|
||||
|
||||
@Injectable()
|
||||
export class LeadFormsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(organizationId: string): Promise<LeadFormDto[]> {
|
||||
const rows = await this.prisma.leadForm.findMany({
|
||||
where: { organizationId },
|
||||
include: { fields: { orderBy: { position: 'asc' } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async get(organizationId: string, id: string): Promise<LeadFormDto> {
|
||||
const row = await this.findInOrg(organizationId, id);
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateLeadFormRequest,
|
||||
): Promise<LeadFormDto> {
|
||||
await this.assertPipeline(organizationId, dto.pipelineId);
|
||||
await this.assertAssignees(organizationId, dto.assigneeIds);
|
||||
const fields = normalizeFields(dto.fields?.length ? dto.fields : DEFAULT_FIELDS);
|
||||
|
||||
const row = await this.prisma.leadForm.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
createPerson: dto.createPerson ?? true,
|
||||
createDeal: dto.createDeal ?? true,
|
||||
pipelineId: emptyToNull(dto.pipelineId ?? null),
|
||||
source: emptyToNull(dto.source ?? null) ?? 'فرم وب',
|
||||
thankYouMessage:
|
||||
emptyToNull(dto.thankYouMessage ?? null) ??
|
||||
'پیام شما ثبت شد. بهزودی با شما تماس میگیریم.',
|
||||
redirectUrl: emptyToNull(dto.redirectUrl ?? null),
|
||||
assigneeIds: dto.assigneeIds ?? [],
|
||||
fields: {
|
||||
create: fields.map((f, index) => ({
|
||||
key: f.key!,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required ?? false,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position ?? index,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { fields: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateLeadFormRequest,
|
||||
): Promise<LeadFormDto> {
|
||||
await this.findInOrg(organizationId, id);
|
||||
if ('pipelineId' in dto) {
|
||||
await this.assertPipeline(organizationId, dto.pipelineId);
|
||||
}
|
||||
if (dto.assigneeIds) {
|
||||
await this.assertAssignees(organizationId, dto.assigneeIds);
|
||||
}
|
||||
|
||||
const data: Prisma.LeadFormUpdateInput = {};
|
||||
if (dto.name !== undefined) data.name = dto.name.trim();
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
if (dto.createPerson !== undefined) data.createPerson = dto.createPerson;
|
||||
if (dto.createDeal !== undefined) data.createDeal = dto.createDeal;
|
||||
if ('pipelineId' in dto) data.pipelineId = emptyToNull(dto.pipelineId ?? null);
|
||||
if ('source' in dto) data.source = emptyToNull(dto.source ?? null);
|
||||
if ('thankYouMessage' in dto) {
|
||||
data.thankYouMessage = emptyToNull(dto.thankYouMessage ?? null);
|
||||
}
|
||||
if ('redirectUrl' in dto) {
|
||||
data.redirectUrl = emptyToNull(dto.redirectUrl ?? null);
|
||||
}
|
||||
if (dto.assigneeIds !== undefined) data.assigneeIds = dto.assigneeIds;
|
||||
|
||||
if (dto.fields) {
|
||||
const fields = normalizeFields(dto.fields);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.leadFormField.deleteMany({ where: { formId: id } });
|
||||
await tx.leadForm.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
fields: {
|
||||
create: fields.map((f, index) => ({
|
||||
key: f.key!,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required ?? false,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position ?? index,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} else if (Object.keys(data).length) {
|
||||
await this.prisma.leadForm.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
return this.get(organizationId, id);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.findInOrg(organizationId, id);
|
||||
await this.prisma.leadForm.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async getPublic(token: string): Promise<PublicLeadFormDto> {
|
||||
const row = await this.prisma.leadForm.findFirst({
|
||||
where: { publicToken: token, isActive: true },
|
||||
include: { fields: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
|
||||
return {
|
||||
name: row.name,
|
||||
thankYouMessage: row.thankYouMessage,
|
||||
fields: row.fields.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async submit(
|
||||
token: string,
|
||||
values: Record<string, string>,
|
||||
): Promise<SubmitLeadFormResponse> {
|
||||
const form = await this.prisma.leadForm.findFirst({
|
||||
where: { publicToken: token, isActive: true },
|
||||
include: { fields: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
|
||||
|
||||
const mapped = mapValues(form.fields, values);
|
||||
|
||||
if (form.createPerson) {
|
||||
if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) {
|
||||
throw new BadRequestException('نام یا نام خانوادگی لازم است');
|
||||
}
|
||||
}
|
||||
|
||||
const ownerId = await this.nextAssignee(form);
|
||||
const actorId =
|
||||
ownerId ??
|
||||
(
|
||||
await this.prisma.user.findFirst({
|
||||
where: { organizationId: form.organizationId, role: 'owner' },
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id;
|
||||
|
||||
if (!actorId) {
|
||||
throw new BadRequestException('سازمان کاربر فعالی برای ثبت سرنخ ندارد');
|
||||
}
|
||||
|
||||
let personId: string | null = null;
|
||||
if (form.createPerson) {
|
||||
personId = await this.upsertPerson(
|
||||
form.organizationId,
|
||||
mapped,
|
||||
ownerId ?? actorId,
|
||||
);
|
||||
}
|
||||
|
||||
if (form.createDeal) {
|
||||
await this.createDeal(form, mapped, personId, actorId, ownerId);
|
||||
} else if (personId && mapped.notes) {
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
notes: mapped.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
thankYouMessage: form.thankYouMessage,
|
||||
redirectUrl: form.redirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private async upsertPerson(
|
||||
organizationId: string,
|
||||
mapped: MappedValues,
|
||||
ownerId: string,
|
||||
): Promise<string> {
|
||||
const email = normalizeEmail(mapped.email);
|
||||
const mobile = normalizePhone(mapped.mobile);
|
||||
const phone = normalizePhone(mapped.phone);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
|
||||
const existing = or.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { organizationId, OR: or },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.person.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
firstName: mapped.firstName?.trim() || existing.firstName,
|
||||
lastName: mapped.lastName?.trim() || existing.lastName,
|
||||
email: email ?? existing.email,
|
||||
mobile: mobile ?? existing.mobile,
|
||||
phone: phone ?? existing.phone,
|
||||
position: emptyToNull(mapped.position) ?? existing.position,
|
||||
notes: emptyToNull(mapped.notes) ?? existing.notes,
|
||||
ownerId: existing.ownerId ?? ownerId,
|
||||
},
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const created = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId,
|
||||
firstName: (mapped.firstName?.trim() || 'سرنخ').slice(0, 80),
|
||||
lastName: (mapped.lastName?.trim() || 'جدید').slice(0, 80),
|
||||
email,
|
||||
mobile,
|
||||
phone,
|
||||
position: emptyToNull(mapped.position),
|
||||
notes: emptyToNull(mapped.notes),
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async createDeal(
|
||||
form: FormRow,
|
||||
mapped: MappedValues,
|
||||
personId: string | null,
|
||||
actorId: string,
|
||||
ownerId: string | null,
|
||||
) {
|
||||
const pipeline =
|
||||
(form.pipelineId
|
||||
? await this.prisma.pipeline.findFirst({
|
||||
where: { id: form.pipelineId, organizationId: form.organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
})
|
||||
: null) ??
|
||||
(await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId: form.organizationId, isDefault: true },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
}));
|
||||
|
||||
if (!pipeline?.stages.length) {
|
||||
throw new BadRequestException('کاریز فعالی برای ثبت معامله وجود ندارد');
|
||||
}
|
||||
|
||||
const stage =
|
||||
pipeline.stages.find((s) => !s.isWon && !s.isLost) ?? pipeline.stages[0];
|
||||
|
||||
const title =
|
||||
mapped.dealTitle?.trim() ||
|
||||
[mapped.firstName, mapped.lastName].filter(Boolean).join(' ').trim() ||
|
||||
`سرنخ از ${form.name}`;
|
||||
|
||||
const maxPos = await this.prisma.deal.aggregate({
|
||||
where: { organizationId: form.organizationId, stageId: stage.id },
|
||||
_max: { position: true },
|
||||
});
|
||||
|
||||
await this.prisma.deal.create({
|
||||
data: {
|
||||
organizationId: form.organizationId,
|
||||
pipelineId: pipeline.id,
|
||||
stageId: stage.id,
|
||||
title: title.slice(0, 200),
|
||||
source: form.source ?? 'فرم وب',
|
||||
notes: emptyToNull(mapped.dealNotes ?? mapped.notes),
|
||||
personId,
|
||||
ownerId: ownerId ?? actorId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
status: 'open',
|
||||
currency: 'IRR',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async nextAssignee(form: FormRow): Promise<string | null> {
|
||||
const ids = form.assigneeIds.filter(Boolean);
|
||||
if (!ids.length) return null;
|
||||
|
||||
const index = form.roundRobinIndex % ids.length;
|
||||
const chosen = ids[index];
|
||||
await this.prisma.leadForm.update({
|
||||
where: { id: form.id },
|
||||
data: { roundRobinIndex: index + 1 },
|
||||
});
|
||||
return chosen;
|
||||
}
|
||||
|
||||
private async findInOrg(organizationId: string, id: string): Promise<FormRow> {
|
||||
const row = await this.prisma.leadForm.findFirst({
|
||||
where: { id, organizationId },
|
||||
include: { fields: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('فرم پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertPipeline(
|
||||
organizationId: string,
|
||||
pipelineId?: string | null,
|
||||
) {
|
||||
const id = emptyToNull(pipelineId ?? null);
|
||||
if (!id) return;
|
||||
const found = await this.prisma.pipeline.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new BadRequestException('کاریز انتخابشده معتبر نیست');
|
||||
}
|
||||
|
||||
private async assertAssignees(
|
||||
organizationId: string,
|
||||
assigneeIds?: string[],
|
||||
) {
|
||||
if (!assigneeIds?.length) return;
|
||||
const count = await this.prisma.user.count({
|
||||
where: { organizationId, id: { in: assigneeIds } },
|
||||
});
|
||||
if (count !== assigneeIds.length) {
|
||||
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: FormRow): LeadFormDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
publicToken: row.publicToken,
|
||||
isActive: row.isActive,
|
||||
createPerson: row.createPerson,
|
||||
createDeal: row.createDeal,
|
||||
pipelineId: row.pipelineId,
|
||||
source: row.source,
|
||||
thankYouMessage: row.thankYouMessage,
|
||||
redirectUrl: row.redirectUrl,
|
||||
assigneeIds: row.assigneeIds,
|
||||
fields: row.fields.map((f) => ({
|
||||
id: f.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position,
|
||||
})),
|
||||
publicPath: `/f/${row.publicToken}`,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type MappedValues = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
mobile?: string;
|
||||
phone?: string;
|
||||
position?: string;
|
||||
dealTitle?: string;
|
||||
dealNotes?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
function mapValues(
|
||||
fields: Array<{ key: string; mapTo: string; required: boolean; label: string }>,
|
||||
values: Record<string, string>,
|
||||
): MappedValues {
|
||||
const mapped: MappedValues = {};
|
||||
for (const field of fields) {
|
||||
const raw = (values?.[field.key] ?? '').toString().trim();
|
||||
if (field.required && !raw) {
|
||||
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
|
||||
}
|
||||
if (!raw) continue;
|
||||
switch (field.mapTo) {
|
||||
case 'person.firstName':
|
||||
mapped.firstName = raw;
|
||||
break;
|
||||
case 'person.lastName':
|
||||
mapped.lastName = raw;
|
||||
break;
|
||||
case 'person.email':
|
||||
mapped.email = raw;
|
||||
break;
|
||||
case 'person.mobile':
|
||||
mapped.mobile = raw;
|
||||
break;
|
||||
case 'person.phone':
|
||||
mapped.phone = raw;
|
||||
break;
|
||||
case 'person.position':
|
||||
mapped.position = raw;
|
||||
break;
|
||||
case 'deal.title':
|
||||
mapped.dealTitle = raw;
|
||||
break;
|
||||
case 'deal.notes':
|
||||
mapped.dealNotes = raw;
|
||||
break;
|
||||
case 'notes':
|
||||
mapped.notes = raw;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
function normalizeFields(fields: LeadFormFieldInput[]): LeadFormFieldInput[] {
|
||||
return fields.map((f, index) => {
|
||||
const key =
|
||||
f.key?.trim() ||
|
||||
slugKey(f.label) ||
|
||||
`field_${index + 1}`;
|
||||
return {
|
||||
...f,
|
||||
key: key.slice(0, 64),
|
||||
label: f.label.trim(),
|
||||
fieldType: f.fieldType || 'text',
|
||||
required: f.required ?? false,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position ?? index,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function slugKey(label: string): string {
|
||||
return label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u0600-\u06FF]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { SubmitLeadFormDto } from './dto';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
|
||||
@Controller('public/forms')
|
||||
export class PublicLeadFormsController {
|
||||
constructor(private readonly leadFormsService: LeadFormsService) {}
|
||||
|
||||
@Get(':token')
|
||||
get(@Param('token') token: string) {
|
||||
return this.leadFormsService.getPublic(token);
|
||||
}
|
||||
|
||||
@Post(':token/submit')
|
||||
submit(@Param('token') token: string, @Body() dto: SubmitLeadFormDto) {
|
||||
return this.leadFormsService.submit(token, dto.values ?? {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: config.get<string>('CORS_ORIGIN') ?? 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = Number(config.get<string>('PORT') ?? 3000);
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Mento API listening on http://localhost:${port}/api`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const NOTE_TYPES = [
|
||||
'general',
|
||||
'call_result',
|
||||
'meeting',
|
||||
'important',
|
||||
'warning',
|
||||
'next_action',
|
||||
] as const;
|
||||
|
||||
export class ListNotesQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
pinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_TYPES)
|
||||
noteType?: (typeof NOTE_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 50;
|
||||
}
|
||||
|
||||
export class CreateNoteDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(8000)
|
||||
body!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_TYPES)
|
||||
noteType?: (typeof NOTE_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
pinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
reminderAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateNoteDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(8000)
|
||||
body?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_TYPES)
|
||||
noteType?: (typeof NOTE_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
pinned?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
color?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
reminderAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto } from './dto';
|
||||
import { NotesService } from './notes.service';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotesController {
|
||||
constructor(private readonly notesService: NotesService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query() query: ListNotesQueryDto,
|
||||
) {
|
||||
return this.notesService.list(user.organizationId, user, {
|
||||
q: query.q,
|
||||
pinned: query.pinned,
|
||||
personId: query.personId,
|
||||
companyId: query.companyId,
|
||||
dealId: query.dealId,
|
||||
tag: query.tag,
|
||||
noteType: query.noteType,
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 50,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.notesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateNoteDto) {
|
||||
return this.notesService.create(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateNoteDto,
|
||||
) {
|
||||
return this.notesService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/pin')
|
||||
togglePin(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.notesService.togglePin(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post(':id/convert-to-task')
|
||||
convertToTask(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.notesService.convertToTask(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/attachments')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadAttachment(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.notesService.addAttachment(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
file,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/attachments/:attachmentId/download')
|
||||
async downloadAttachment(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
): Promise<StreamableFile> {
|
||||
const { stream, fileName, mimeType } =
|
||||
await this.notesService.openAttachmentDownload(
|
||||
user.organizationId,
|
||||
id,
|
||||
attachmentId,
|
||||
user,
|
||||
);
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return new StreamableFile(stream, {
|
||||
type: mimeType,
|
||||
disposition: `attachment; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id/attachments/:attachmentId')
|
||||
async removeAttachment(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
) {
|
||||
await this.notesService.removeAttachment(
|
||||
user.organizationId,
|
||||
id,
|
||||
attachmentId,
|
||||
user,
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
await this.notesService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotesController } from './notes.controller';
|
||||
import { NotesService } from './notes.service';
|
||||
|
||||
@Module({
|
||||
controllers: [NotesController],
|
||||
providers: [NotesService],
|
||||
exports: [NotesService],
|
||||
})
|
||||
export class NotesModule {}
|
||||
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateNoteRequest,
|
||||
NoteAttachmentDto,
|
||||
NoteDto,
|
||||
PaginatedResponse,
|
||||
UpdateNoteRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as path from 'path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import {
|
||||
type Actor,
|
||||
noteAccessWhere,
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
|
||||
type NoteActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
type NoteRow = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
body: string;
|
||||
noteType: string;
|
||||
pinned: boolean;
|
||||
color: string | null;
|
||||
tags: string[];
|
||||
reminderAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
author: { id: string; fullName: string } | null;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
const include = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
author: { select: { id: true, fullName: true } },
|
||||
attachments: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
},
|
||||
} satisfies Prisma.NoteInclude;
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_ATTACHMENTS_PER_NOTE = 8;
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class NotesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
actor: NoteActor,
|
||||
opts: {
|
||||
q?: string;
|
||||
pinned?: boolean;
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
tag?: string;
|
||||
noteType?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<PaginatedResponse<NoteDto>> {
|
||||
const page = opts.page ?? 1;
|
||||
const limit = opts.limit ?? 50;
|
||||
const access = noteAccessWhere(actor);
|
||||
const search = opts.q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: opts.q.trim(), mode: 'insensitive' as const } },
|
||||
{ body: { contains: opts.q.trim(), mode: 'insensitive' as const } },
|
||||
{ tags: { has: opts.q.trim() } },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const where: Prisma.NoteWhereInput = {
|
||||
organizationId,
|
||||
...(opts.pinned != null ? { pinned: opts.pinned } : {}),
|
||||
...(opts.personId ? { personId: opts.personId } : {}),
|
||||
...(opts.companyId ? { companyId: opts.companyId } : {}),
|
||||
...(opts.dealId ? { dealId: opts.dealId } : {}),
|
||||
...(opts.noteType ? { noteType: opts.noteType } : {}),
|
||||
...(opts.tag?.trim() ? { tags: { has: opts.tag.trim() } } : {}),
|
||||
...(search
|
||||
? { AND: [access, search] }
|
||||
: access),
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.note.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.note.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: NoteActor,
|
||||
): Promise<NoteDto> {
|
||||
const row = await this.prisma.note.findFirst({
|
||||
where: { id, organizationId, ...noteAccessWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('یادداشت پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
dto: CreateNoteRequest,
|
||||
actor?: NoteActor,
|
||||
): Promise<NoteDto> {
|
||||
await this.assertLinks(
|
||||
organizationId,
|
||||
actor ?? { userId, role: 'owner' },
|
||||
dto,
|
||||
);
|
||||
const row = await this.prisma.note.create({
|
||||
data: {
|
||||
organizationId,
|
||||
authorId: userId,
|
||||
title: emptyToNull(dto.title),
|
||||
body: dto.body.trim(),
|
||||
noteType: dto.noteType?.trim() || 'general',
|
||||
pinned: dto.pinned ?? false,
|
||||
color: emptyToNull(dto.color) ?? 'default',
|
||||
tags: normalizeTags(dto.tags),
|
||||
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
},
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: NoteActor,
|
||||
dto: UpdateNoteRequest,
|
||||
): Promise<NoteDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.assertLinks(organizationId, actor, dto);
|
||||
|
||||
const data: Prisma.NoteUncheckedUpdateInput = {};
|
||||
if ('title' in dto) data.title = emptyToNull(dto.title);
|
||||
if (dto.body !== undefined) data.body = dto.body.trim();
|
||||
if (dto.noteType !== undefined) data.noteType = dto.noteType.trim() || 'general';
|
||||
if (dto.pinned !== undefined) data.pinned = dto.pinned;
|
||||
if ('color' in dto) data.color = emptyToNull(dto.color) ?? 'default';
|
||||
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
|
||||
if ('reminderAt' in dto) {
|
||||
data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
|
||||
}
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
|
||||
const row = await this.prisma.note.update({
|
||||
where: { id },
|
||||
data,
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async togglePin(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: NoteActor,
|
||||
): Promise<NoteDto> {
|
||||
const existing = await this.prisma.note.findFirst({
|
||||
where: { id, organizationId, ...noteAccessWhere(actor) },
|
||||
select: { pinned: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('یادداشت پیدا نشد');
|
||||
const row = await this.prisma.note.update({
|
||||
where: { id },
|
||||
data: { pinned: !existing.pinned },
|
||||
include,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async convertToTask(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
actor: NoteActor,
|
||||
): Promise<{ note: NoteDto; activityId: string }> {
|
||||
const note = await this.prisma.note.findFirst({
|
||||
where: { id, organizationId, ...noteAccessWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
|
||||
|
||||
const activity = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId,
|
||||
type: 'task',
|
||||
title: (note.title?.trim() || note.body.trim()).slice(0, 200),
|
||||
description: note.body,
|
||||
dueAt: note.reminderAt,
|
||||
personId: note.personId,
|
||||
companyId: note.companyId,
|
||||
dealId: note.dealId,
|
||||
assigneeId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return { note: this.toDto(note), activityId: activity.id };
|
||||
}
|
||||
|
||||
async remove(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: NoteActor,
|
||||
): Promise<void> {
|
||||
const note = await this.prisma.note.findFirst({
|
||||
where: { id, organizationId, ...noteAccessWhere(actor) },
|
||||
include: { attachments: { select: { storageKey: true } } },
|
||||
});
|
||||
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
|
||||
|
||||
for (const att of note.attachments) {
|
||||
await this.files.remove(att.storageKey);
|
||||
}
|
||||
await this.prisma.note.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addAttachment(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
noteId: string,
|
||||
file: Express.Multer.File,
|
||||
actor: NoteActor,
|
||||
): Promise<NoteAttachmentDto> {
|
||||
await this.ensureExists(organizationId, noteId, actor);
|
||||
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایلی ارسال نشده است');
|
||||
}
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
throw new BadRequestException('حداکثر حجم فایل ۱۰ مگابایت است');
|
||||
}
|
||||
|
||||
const mimeType = (file.mimetype || '').toLowerCase();
|
||||
if (!ALLOWED_MIME.has(mimeType)) {
|
||||
throw new BadRequestException(
|
||||
'نوع فایل مجاز نیست. تصویر، PDF، Word، Excel، متن یا ZIP بفرستید.',
|
||||
);
|
||||
}
|
||||
|
||||
const count = await this.prisma.noteAttachment.count({ where: { noteId } });
|
||||
if (count >= MAX_ATTACHMENTS_PER_NOTE) {
|
||||
throw new BadRequestException(
|
||||
`حداکثر ${MAX_ATTACHMENTS_PER_NOTE} پیوست برای هر یادداشت مجاز است`,
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = sanitizeFileName(file.originalname);
|
||||
const ext = path.extname(fileName).slice(0, 12);
|
||||
const attachmentId = randomUUID().replace(/-/g, '').slice(0, 24);
|
||||
const storageKey = path
|
||||
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
|
||||
try {
|
||||
const row = await this.prisma.noteAttachment.create({
|
||||
data: {
|
||||
id: attachmentId,
|
||||
organizationId,
|
||||
noteId,
|
||||
fileName,
|
||||
mimeType,
|
||||
sizeBytes: file.size,
|
||||
storageKey,
|
||||
uploadedById: userId,
|
||||
},
|
||||
});
|
||||
return this.toAttachmentDto(row);
|
||||
} catch (err) {
|
||||
await this.files.remove(storageKey);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async removeAttachment(
|
||||
organizationId: string,
|
||||
noteId: string,
|
||||
attachmentId: string,
|
||||
actor: NoteActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, noteId, actor);
|
||||
const row = await this.prisma.noteAttachment.findFirst({
|
||||
where: { id: attachmentId, noteId, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('پیوست پیدا نشد');
|
||||
await this.files.remove(row.storageKey);
|
||||
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
|
||||
}
|
||||
|
||||
async openAttachmentDownload(
|
||||
organizationId: string,
|
||||
noteId: string,
|
||||
attachmentId: string,
|
||||
actor: NoteActor,
|
||||
): Promise<{
|
||||
stream: ReturnType<LocalFileStorage['openReadStream']>;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
}> {
|
||||
await this.ensureExists(organizationId, noteId, actor);
|
||||
const row = await this.prisma.noteAttachment.findFirst({
|
||||
where: { id: attachmentId, noteId, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('پیوست پیدا نشد');
|
||||
return {
|
||||
stream: this.files.openReadStream(row.storageKey),
|
||||
fileName: row.fileName,
|
||||
mimeType: row.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: NoteActor,
|
||||
) {
|
||||
const found = await this.prisma.note.findFirst({
|
||||
where: { id, organizationId, ...noteAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('یادداشت پیدا نشد');
|
||||
}
|
||||
|
||||
private async assertLinks(
|
||||
organizationId: string,
|
||||
actor: NoteActor,
|
||||
dto: {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
},
|
||||
) {
|
||||
const personId = emptyToNull(dto.personId ?? null);
|
||||
const companyId = emptyToNull(dto.companyId ?? null);
|
||||
const dealId = emptyToNull(dto.dealId ?? null);
|
||||
const scope = ownerWhere(actor);
|
||||
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('شخص انتخابشده معتبر نیست');
|
||||
}
|
||||
if (companyId) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت انتخابشده معتبر نیست');
|
||||
}
|
||||
if (dealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: NoteRow): NoteDto {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
noteType: row.noteType,
|
||||
pinned: row.pinned,
|
||||
color: row.color,
|
||||
tags: row.tags,
|
||||
reminderAt: row.reminderAt?.toISOString() ?? null,
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
||||
}
|
||||
: null,
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
author: row.author,
|
||||
attachments: (row.attachments ?? []).map((a) => this.toAttachmentDto(a)),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toAttachmentDto(row: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
}): NoteAttachmentDto {
|
||||
return {
|
||||
id: row.id,
|
||||
fileName: row.fileName,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTags(tags?: string[]): string[] {
|
||||
if (!tags?.length) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const tag of tags) {
|
||||
const cleaned = tag.trim().replace(/^#/, '');
|
||||
if (!cleaned || seen.has(cleaned)) continue;
|
||||
seen.add(cleaned);
|
||||
result.push(cleaned.slice(0, 40));
|
||||
}
|
||||
return result.slice(0, 12);
|
||||
}
|
||||
|
||||
function sanitizeFileName(raw: string): string {
|
||||
const base = path.basename(raw || 'file').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||
const trimmed = base.trim() || 'file';
|
||||
return trimmed.slice(0, 180);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
class ListNotificationsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 30;
|
||||
}
|
||||
|
||||
@Controller('notifications')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
@Query() query: ListNotificationsQueryDto,
|
||||
) {
|
||||
return this.notificationsService.list(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
query.limit ?? 30,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
async unreadCount(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
) {
|
||||
const count = await this.notificationsService.unreadCount(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@Patch('read-all')
|
||||
async markAllRead(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
) {
|
||||
const count = await this.notificationsService.markAllRead(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
);
|
||||
return { ok: true, count };
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
async markRead(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const item = await this.notificationsService.markRead(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
);
|
||||
return item ?? { ok: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Module({
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type CreateNotificationInput = {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
link?: string | null;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
/** If true, skip when an unread notification with same type+entityId exists for user */
|
||||
dedupe?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
limit = 30,
|
||||
): Promise<NotificationsListDto> {
|
||||
await this.syncDueItems(organizationId, userId);
|
||||
|
||||
const [items, unreadCount] = await this.prisma.$transaction([
|
||||
this.prisma.notification.findMany({
|
||||
where: { organizationId, userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(Math.max(limit, 1), 100),
|
||||
}),
|
||||
this.prisma.notification.count({
|
||||
where: { organizationId, userId, readAt: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((row) => this.toDto(row)),
|
||||
unreadCount,
|
||||
};
|
||||
}
|
||||
|
||||
async unreadCount(organizationId: string, userId: string): Promise<number> {
|
||||
await this.syncDueItems(organizationId, userId);
|
||||
return this.prisma.notification.count({
|
||||
where: { organizationId, userId, readAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
async markRead(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<NotificationDto | null> {
|
||||
const existing = await this.prisma.notification.findFirst({
|
||||
where: { id, organizationId, userId },
|
||||
});
|
||||
if (!existing) return null;
|
||||
if (existing.readAt) return this.toDto(existing);
|
||||
const row = await this.prisma.notification.update({
|
||||
where: { id },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async markAllRead(organizationId: string, userId: string): Promise<number> {
|
||||
const result = await this.prisma.notification.updateMany({
|
||||
where: { organizationId, userId, readAt: null },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async create(input: CreateNotificationInput): Promise<boolean> {
|
||||
if (input.dedupe && input.entityId) {
|
||||
const existing = await this.prisma.notification.findFirst({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
entityId: input.entityId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return false;
|
||||
}
|
||||
|
||||
await this.prisma.notification.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
body: input.body ?? null,
|
||||
link: input.link ?? null,
|
||||
entityType: input.entityType ?? null,
|
||||
entityId: input.entityId ?? null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Create overdue/reminder notifications for the current user (idempotent). */
|
||||
async syncDueItems(organizationId: string, userId: string): Promise<void> {
|
||||
await this.collapseDuplicates(userId);
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const overdue = await this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
assigneeId: userId,
|
||||
status: 'open',
|
||||
dueAt: { lt: startOfToday },
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
for (const activity of overdue) {
|
||||
await this.create({
|
||||
organizationId,
|
||||
userId,
|
||||
type: 'activity_overdue',
|
||||
title: 'فعالیت عقبافتاده',
|
||||
body: activity.title,
|
||||
link: `/activities/${activity.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: activity.id,
|
||||
dedupe: true,
|
||||
});
|
||||
}
|
||||
|
||||
const dueNotes = await this.prisma.note.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
authorId: userId,
|
||||
reminderAt: { lte: now },
|
||||
},
|
||||
select: { id: true, title: true, body: true },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
for (const note of dueNotes) {
|
||||
await this.create({
|
||||
organizationId,
|
||||
userId,
|
||||
type: 'note_reminder',
|
||||
title: 'یادآوری یادداشت',
|
||||
body: note.title || note.body.slice(0, 80),
|
||||
link: '/notes',
|
||||
entityType: 'note',
|
||||
entityId: note.id,
|
||||
dedupe: true,
|
||||
});
|
||||
// یادآور فقط یکبار شلیک میشود؛ تکرار بعد از خواندن نباید رخ دهد
|
||||
await this.prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: { reminderAt: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep newest notification per type+entity; drop repeated sync spam. */
|
||||
private async collapseDuplicates(userId: string): Promise<void> {
|
||||
const rows = await this.prisma.notification.findMany({
|
||||
where: { userId, entityId: { not: null } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, type: true, entityId: true },
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
const toDelete: string[] = [];
|
||||
for (const row of rows) {
|
||||
const key = `${row.type}:${row.entityId}`;
|
||||
if (seen.has(key)) toDelete.push(row.id);
|
||||
else seen.add(key);
|
||||
}
|
||||
if (toDelete.length) {
|
||||
await this.prisma.notification.deleteMany({
|
||||
where: { id: { in: toDelete } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
link: string | null;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
readAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): NotificationDto {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
link: row.link,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
readAt: row.readAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ExchangeRateInputDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(8)
|
||||
currency!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.000001)
|
||||
rateToBase!: number;
|
||||
}
|
||||
|
||||
export class UpdateOrgCurrenciesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(8)
|
||||
baseCurrency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
enabledCurrencies?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ExchangeRateInputDto)
|
||||
rates?: ExchangeRateInputDto[];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { UpdateOrgCurrenciesDto } from './dto';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@Controller('organizations')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class OrganizationsController {
|
||||
constructor(private readonly organizationsService: OrganizationsService) {}
|
||||
|
||||
@Get('current')
|
||||
current(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.organizationsService.getCurrent(user.organizationId);
|
||||
}
|
||||
|
||||
@Get('currency-catalog')
|
||||
catalog() {
|
||||
return this.organizationsService.catalog();
|
||||
}
|
||||
|
||||
@Patch('current/currencies')
|
||||
@Roles('owner', 'admin')
|
||||
updateCurrencies(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: UpdateOrgCurrenciesDto,
|
||||
) {
|
||||
return this.organizationsService.updateCurrencies(
|
||||
user.organizationId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OrganizationsController],
|
||||
providers: [OrganizationsService],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
OrganizationDto,
|
||||
UpdateOrgCurrenciesRequest,
|
||||
} from '@mento/shared';
|
||||
import { CURRENCY_CATALOG } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { isKnownCurrency } from '../common/currency';
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCurrent(organizationId: string): Promise<OrganizationDto> {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
include: {
|
||||
exchangeRates: { orderBy: { currency: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('سازمان پیدا نشد');
|
||||
}
|
||||
return this.toDto(org);
|
||||
}
|
||||
|
||||
async updateCurrencies(
|
||||
organizationId: string,
|
||||
dto: UpdateOrgCurrenciesRequest,
|
||||
): Promise<OrganizationDto> {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
});
|
||||
if (!org) throw new NotFoundException('سازمان پیدا نشد');
|
||||
|
||||
const baseCurrency = (
|
||||
dto.baseCurrency?.trim() ||
|
||||
org.baseCurrency ||
|
||||
'IRR'
|
||||
).toUpperCase();
|
||||
if (!isKnownCurrency(baseCurrency)) {
|
||||
throw new BadRequestException('ارز پایه معتبر نیست');
|
||||
}
|
||||
|
||||
let enabled = (
|
||||
dto.enabledCurrencies?.map((c) => c.trim().toUpperCase()) ??
|
||||
org.enabledCurrencies
|
||||
).filter(Boolean);
|
||||
if (!enabled.includes(baseCurrency)) {
|
||||
enabled = [baseCurrency, ...enabled];
|
||||
}
|
||||
enabled = [...new Set(enabled)];
|
||||
for (const code of enabled) {
|
||||
if (!isKnownCurrency(code)) {
|
||||
throw new BadRequestException(`ارز ${code} پشتیبانی نمیشود`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.rates) {
|
||||
for (const row of dto.rates) {
|
||||
const code = row.currency.trim().toUpperCase();
|
||||
if (!isKnownCurrency(code)) {
|
||||
throw new BadRequestException(`ارز ${code} پشتیبانی نمیشود`);
|
||||
}
|
||||
if (code === baseCurrency) continue;
|
||||
if (row.rateToBase <= 0) {
|
||||
throw new BadRequestException('نرخ باید بزرگتر از صفر باشد');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: {
|
||||
baseCurrency,
|
||||
enabledCurrencies: enabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.rates) {
|
||||
for (const row of dto.rates) {
|
||||
const code = row.currency.trim().toUpperCase();
|
||||
if (code === baseCurrency) {
|
||||
await tx.exchangeRate.deleteMany({
|
||||
where: { organizationId, currency: code },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await tx.exchangeRate.upsert({
|
||||
where: {
|
||||
organizationId_currency: { organizationId, currency: code },
|
||||
},
|
||||
create: {
|
||||
organizationId,
|
||||
currency: code,
|
||||
rateToBase: new Prisma.Decimal(row.rateToBase),
|
||||
},
|
||||
update: {
|
||||
rateToBase: new Prisma.Decimal(row.rateToBase),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.exchangeRate.deleteMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [
|
||||
{ currency: baseCurrency },
|
||||
{ currency: { notIn: enabled } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return this.getCurrent(organizationId);
|
||||
}
|
||||
|
||||
catalog() {
|
||||
return CURRENCY_CATALOG;
|
||||
}
|
||||
|
||||
private toDto(org: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
baseCurrency: string;
|
||||
enabledCurrencies: string[];
|
||||
exchangeRates: Array<{
|
||||
currency: string;
|
||||
rateToBase: Prisma.Decimal;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
}): OrganizationDto {
|
||||
return {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
baseCurrency: org.baseCurrency,
|
||||
enabledCurrencies: org.enabledCurrencies,
|
||||
exchangeRates: org.exchangeRates.map((r) => ({
|
||||
currency: r.currency,
|
||||
rateToBase: Number(r.rateToBase),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||
import { DealsService } from './deals.service';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('deals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DealsController {
|
||||
constructor(private readonly dealsService: DealsService) {}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.dealsService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateDealDto) {
|
||||
return this.dealsService.create(user.organizationId, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateDealDto,
|
||||
) {
|
||||
return this.dealsService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/move')
|
||||
move(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveDealDto,
|
||||
) {
|
||||
return this.dealsService.move(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
await this.dealsService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateDealRequest,
|
||||
DealDto,
|
||||
DealLineItemInput,
|
||||
MoveDealRequest,
|
||||
UpdateDealRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
import { toJsonCustomFields } from '../common/custom-fields';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
|
||||
const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
lineItems: { orderBy: { position: 'asc' as const } },
|
||||
} satisfies Prisma.DealInclude;
|
||||
|
||||
type DealActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@Injectable()
|
||||
export class DealsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pipelines: PipelinesService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
): Promise<DealDto> {
|
||||
const row = await this.findDeal(organizationId, id, actor);
|
||||
return this.pipelines.toDealDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
actor: DealActor,
|
||||
dto: CreateDealRequest,
|
||||
): Promise<DealDto> {
|
||||
const pipeline =
|
||||
dto.pipelineId != null
|
||||
? await this.prisma.pipeline.findFirst({
|
||||
where: { id: dto.pipelineId, organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
})
|
||||
: null;
|
||||
|
||||
const resolvedPipeline =
|
||||
pipeline ??
|
||||
(await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, isDefault: true },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
}));
|
||||
|
||||
if (!resolvedPipeline) {
|
||||
await this.pipelines.ensureDefaultPipeline(organizationId);
|
||||
return this.create(organizationId, actor, dto);
|
||||
}
|
||||
|
||||
const stage =
|
||||
(dto.stageId
|
||||
? resolvedPipeline.stages.find((s) => s.id === dto.stageId)
|
||||
: resolvedPipeline.stages.find((s) => !s.isWon && !s.isLost)) ??
|
||||
resolvedPipeline.stages[0];
|
||||
|
||||
if (!stage) {
|
||||
throw new BadRequestException('کاریز مرحلهای ندارد');
|
||||
}
|
||||
|
||||
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
|
||||
if (dto.lineItems?.length) {
|
||||
await this.assertProducts(organizationId, dto.lineItems);
|
||||
}
|
||||
|
||||
const maxPos = await this.prisma.deal.aggregate({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
_max: { position: true },
|
||||
});
|
||||
|
||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||
const amountFromLines = sumLineItems(dto.lineItems);
|
||||
const currency =
|
||||
dto.lineItems?.[0]?.currency?.trim() ||
|
||||
(dto.currency ?? 'IRR').trim() ||
|
||||
'IRR';
|
||||
|
||||
const created = await this.prisma.deal.create({
|
||||
data: {
|
||||
organizationId,
|
||||
pipelineId: resolvedPipeline.id,
|
||||
stageId: stage.id,
|
||||
title: dto.title.trim(),
|
||||
amount:
|
||||
amountFromLines != null
|
||||
? new Prisma.Decimal(amountFromLines)
|
||||
: dto.amount == null || Number.isNaN(Number(dto.amount))
|
||||
? null
|
||||
: new Prisma.Decimal(dto.amount),
|
||||
currency,
|
||||
status,
|
||||
source: emptyToNull(dto.source),
|
||||
notes: emptyToNull(dto.notes),
|
||||
tags: normalizeTags(dto.tags),
|
||||
isStale: dto.isStale ?? false,
|
||||
isRotten: dto.isRotten ?? false,
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
expectedCloseAt: dto.expectedCloseAt
|
||||
? new Date(dto.expectedCloseAt)
|
||||
: null,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(dto.lineItems?.length
|
||||
? {
|
||||
lineItems: {
|
||||
create: dto.lineItems.map((item, index) => ({
|
||||
productId: emptyToNull(item.productId ?? null),
|
||||
name: item.name.trim(),
|
||||
quantity: new Prisma.Decimal(item.quantity),
|
||||
unitPrice: new Prisma.Decimal(item.unitPrice),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
position: index,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
return this.pipelines.toDealDto(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
dto: UpdateDealRequest,
|
||||
): Promise<DealDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
|
||||
if (dto.lineItems) {
|
||||
await this.assertProducts(organizationId, dto.lineItems);
|
||||
}
|
||||
|
||||
const data: Prisma.DealUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if ('amount' in dto && dto.lineItems === undefined) {
|
||||
data.amount =
|
||||
dto.amount == null || Number.isNaN(Number(dto.amount))
|
||||
? null
|
||||
: new Prisma.Decimal(dto.amount);
|
||||
}
|
||||
if (dto.currency !== undefined && dto.lineItems === undefined) {
|
||||
data.currency = dto.currency.trim() || 'IRR';
|
||||
}
|
||||
if ('source' in dto) data.source = emptyToNull(dto.source);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
|
||||
if (dto.isStale !== undefined) data.isStale = dto.isStale;
|
||||
if (dto.isRotten !== undefined) data.isRotten = dto.isRotten;
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if ('lostReason' in dto) data.lostReason = emptyToNull(dto.lostReason);
|
||||
if ('expectedCloseAt' in dto) {
|
||||
data.expectedCloseAt = dto.expectedCloseAt
|
||||
? new Date(dto.expectedCloseAt)
|
||||
: null;
|
||||
}
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
}
|
||||
|
||||
if (dto.lineItems !== undefined) {
|
||||
const total = sumLineItems(dto.lineItems);
|
||||
data.amount =
|
||||
total == null ? null : new Prisma.Decimal(total);
|
||||
if (dto.lineItems[0]?.currency) {
|
||||
data.currency = dto.lineItems[0].currency.trim() || 'IRR';
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.lineItems !== undefined) {
|
||||
await tx.dealLineItem.deleteMany({ where: { dealId: id } });
|
||||
if (dto.lineItems.length) {
|
||||
const currency =
|
||||
(typeof data.currency === 'string' ? data.currency : null) ||
|
||||
'IRR';
|
||||
await tx.dealLineItem.createMany({
|
||||
data: dto.lineItems.map((item, index) => ({
|
||||
dealId: id,
|
||||
productId: emptyToNull(item.productId ?? null),
|
||||
name: item.name.trim(),
|
||||
quantity: new Prisma.Decimal(item.quantity),
|
||||
unitPrice: new Prisma.Decimal(item.unitPrice),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
await tx.deal.update({ where: { id }, data });
|
||||
});
|
||||
|
||||
return this.get(organizationId, id, actor);
|
||||
}
|
||||
|
||||
async move(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
dto: MoveDealRequest,
|
||||
): Promise<DealDto> {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
pipelineId: true,
|
||||
stageId: true,
|
||||
ownerId: true,
|
||||
},
|
||||
});
|
||||
if (!deal) throw new NotFoundException('معامله پیدا نشد');
|
||||
|
||||
const stage = await this.prisma.pipelineStage.findFirst({
|
||||
where: { id: dto.stageId, pipelineId: deal.pipelineId },
|
||||
});
|
||||
if (!stage) {
|
||||
throw new BadRequestException('مرحله در این کاریز معتبر نیست');
|
||||
}
|
||||
|
||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||
const position =
|
||||
dto.position ??
|
||||
((
|
||||
await this.prisma.deal.aggregate({
|
||||
where: { organizationId, stageId: stage.id, NOT: { id } },
|
||||
_max: { position: true },
|
||||
})
|
||||
)._max.position ?? -1) + 1;
|
||||
|
||||
const row = await this.prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId: stage.id,
|
||||
position,
|
||||
status,
|
||||
lostReason: stage.isLost ? undefined : null,
|
||||
},
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
if (deal.ownerId && deal.stageId !== stage.id) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: deal.ownerId,
|
||||
type: 'deal_moved',
|
||||
title: 'جابهجایی معامله در کاریز',
|
||||
body: `«${deal.title}» به مرحله «${stage.name}» منتقل شد`,
|
||||
link: `/deals/${deal.id}`,
|
||||
entityType: 'deal',
|
||||
entityId: deal.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.pipelines.toDealDto(row);
|
||||
}
|
||||
|
||||
async remove(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.prisma.deal.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
) {
|
||||
const found = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('معامله پیدا نشد');
|
||||
}
|
||||
|
||||
private async findDeal(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
) {
|
||||
const row = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
include: dealInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('معامله پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertProducts(
|
||||
organizationId: string,
|
||||
items: DealLineItemInput[],
|
||||
) {
|
||||
const ids = [
|
||||
...new Set(
|
||||
items
|
||||
.map((i) => emptyToNull(i.productId ?? null))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
if (!ids.length) return;
|
||||
const count = await this.prisma.product.count({
|
||||
where: { organizationId, id: { in: ids } },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('یکی از محصولات انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLinks(
|
||||
organizationId: string,
|
||||
actor: DealActor,
|
||||
personId?: string | null,
|
||||
companyId?: string | null,
|
||||
) {
|
||||
const pId = emptyToNull(personId ?? null);
|
||||
const cId = emptyToNull(companyId ?? null);
|
||||
const scope = ownerWhere(actor);
|
||||
if (pId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: pId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('شخص انتخابشده معتبر نیست');
|
||||
}
|
||||
if (cId) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: cId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTags(tags?: string[]): string[] {
|
||||
if (!tags?.length) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of tags) {
|
||||
const tag = raw.trim();
|
||||
if (!tag) continue;
|
||||
const key = tag.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(tag.slice(0, 40));
|
||||
}
|
||||
return out.slice(0, 20);
|
||||
}
|
||||
|
||||
function sumLineItems(items?: DealLineItemInput[]): number | null {
|
||||
if (!items?.length) return null;
|
||||
return items.reduce(
|
||||
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
|
||||
0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePipelineDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class DealLineItemInputDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
quantity!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class CreateDealDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pipelineId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
source?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isStale?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRotten?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
expectedCloseAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DealLineItemInputDto)
|
||||
lineItems?: DealLineItemInputDto[];
|
||||
}
|
||||
|
||||
export class UpdateDealDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
source?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isStale?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRotten?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
lostReason?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
expectedCloseAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DealLineItemInputDto)
|
||||
lineItems?: DealLineItemInputDto[];
|
||||
}
|
||||
|
||||
export class MoveDealDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreatePipelineDto } from './dto';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
@Controller('pipelines')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class PipelinesController {
|
||||
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.pipelinesService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Get('board')
|
||||
board(
|
||||
@CurrentUser() user: { organizationId: string; userId: string; role: string },
|
||||
@Query('pipelineId') pipelineId?: string,
|
||||
) {
|
||||
return this.pipelinesService.getBoard(user.organizationId, pipelineId, {
|
||||
userId: user.userId,
|
||||
role: user.role,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreatePipelineDto,
|
||||
) {
|
||||
return this.pipelinesService.create(user.organizationId, dto.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DealsController } from './deals.controller';
|
||||
import { DealsService } from './deals.service';
|
||||
import { PipelinesController } from './pipelines.controller';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule, AuthModule],
|
||||
controllers: [PipelinesController, DealsController],
|
||||
providers: [PipelinesService, DealsService],
|
||||
exports: [PipelinesService, DealsService],
|
||||
})
|
||||
export class PipelinesModule {}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
DealDto,
|
||||
PipelineBoardDto,
|
||||
PipelineDto,
|
||||
PipelineStageDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { asCustomFields } from '../common/custom-fields';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
|
||||
const DEFAULT_STAGES = [
|
||||
{ name: 'سرنخ', isWon: false, isLost: false },
|
||||
{ name: 'تماس اول', isWon: false, isLost: false },
|
||||
{ name: 'پیشنهاد', isWon: false, isLost: false },
|
||||
{ name: 'مذاکره', isWon: false, isLost: false },
|
||||
{ name: 'برنده', isWon: true, isLost: false },
|
||||
{ name: 'از دسترفته', isWon: false, isLost: true },
|
||||
] as const;
|
||||
|
||||
type DealRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: Prisma.Decimal | null;
|
||||
currency: string;
|
||||
status: 'open' | 'won' | 'lost';
|
||||
source: string | null;
|
||||
lostReason: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
isStale: boolean;
|
||||
isRotten: boolean;
|
||||
customFields: unknown;
|
||||
expectedCloseAt: Date | null;
|
||||
position: number;
|
||||
pipelineId: string;
|
||||
stageId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
owner: { id: string; fullName: string } | null;
|
||||
lineItems?: Array<{
|
||||
id: string;
|
||||
productId: string | null;
|
||||
name: string;
|
||||
quantity: Prisma.Decimal;
|
||||
unitPrice: Prisma.Decimal;
|
||||
currency: string;
|
||||
position: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PipelinesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaultPipeline(organizationId: string): Promise<PipelineDto> {
|
||||
const existing = await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, isDefault: true },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (existing) return this.toPipelineDto(existing);
|
||||
|
||||
const created = await this.prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: 'کاریز فروش',
|
||||
isDefault: true,
|
||||
stages: {
|
||||
create: DEFAULT_STAGES.map((s, index) => ({
|
||||
name: s.name,
|
||||
position: index,
|
||||
isWon: s.isWon,
|
||||
isLost: s.isLost,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
return this.toPipelineDto(created);
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<PipelineDto[]> {
|
||||
await this.ensureDefaultPipeline(organizationId);
|
||||
const rows = await this.prisma.pipeline.findMany({
|
||||
where: { organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toPipelineDto(row));
|
||||
}
|
||||
|
||||
async create(organizationId: string, name: string): Promise<PipelineDto> {
|
||||
await this.ensureDefaultPipeline(organizationId);
|
||||
const created = await this.prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: name.trim(),
|
||||
isDefault: false,
|
||||
stages: {
|
||||
create: DEFAULT_STAGES.map((s, index) => ({
|
||||
name: s.name,
|
||||
position: index,
|
||||
isWon: s.isWon,
|
||||
isLost: s.isLost,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
return this.toPipelineDto(created);
|
||||
}
|
||||
|
||||
async getBoard(
|
||||
organizationId: string,
|
||||
pipelineId?: string,
|
||||
actor?: Pick<Actor, 'userId' | 'role'>,
|
||||
): Promise<PipelineBoardDto> {
|
||||
const pipeline = pipelineId
|
||||
? await this.prisma.pipeline.findFirst({
|
||||
where: { id: pipelineId, organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
})
|
||||
: null;
|
||||
|
||||
const resolved =
|
||||
pipeline ??
|
||||
(await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, isDefault: true },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
}));
|
||||
|
||||
if (!resolved) {
|
||||
const created = await this.ensureDefaultPipeline(organizationId);
|
||||
return this.getBoard(organizationId, created.id, actor);
|
||||
}
|
||||
|
||||
const deals = await this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
pipelineId: resolved.id,
|
||||
...(actor ? ownerWhere(actor) : {}),
|
||||
},
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
|
||||
const rateMap = await loadOrgRates(this.prisma, organizationId);
|
||||
|
||||
const byStage = new Map<string, DealDto[]>();
|
||||
for (const stage of resolved.stages) byStage.set(stage.id, []);
|
||||
for (const deal of deals) {
|
||||
const list = byStage.get(deal.stageId);
|
||||
if (list) list.push(this.toDealDto(deal));
|
||||
}
|
||||
|
||||
return {
|
||||
pipeline: this.toPipelineDto(resolved),
|
||||
stages: resolved.stages.map((stage) => {
|
||||
const stageDeals = byStage.get(stage.id) ?? [];
|
||||
return {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
position: stage.position,
|
||||
isWon: stage.isWon,
|
||||
isLost: stage.isLost,
|
||||
deals: stageDeals,
|
||||
totalAmount: stageDeals.reduce(
|
||||
(sum, d) =>
|
||||
sum +
|
||||
decimalToBase(
|
||||
d.amount == null ? null : new Prisma.Decimal(d.amount),
|
||||
d.currency,
|
||||
rateMap,
|
||||
),
|
||||
0,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
toDealDto(row: DealRow): DealDto {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
amount: row.amount == null ? null : Number(row.amount),
|
||||
currency: row.currency,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
lostReason: row.lostReason,
|
||||
notes: row.notes,
|
||||
tags: row.tags ?? [],
|
||||
isStale: row.isStale,
|
||||
isRotten: row.isRotten,
|
||||
customFields: asCustomFields(row.customFields),
|
||||
expectedCloseAt: row.expectedCloseAt?.toISOString() ?? null,
|
||||
position: row.position,
|
||||
pipelineId: row.pipelineId,
|
||||
stageId: row.stageId,
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
||||
}
|
||||
: null,
|
||||
company: row.company,
|
||||
owner: row.owner,
|
||||
lineItems: (row.lineItems ?? []).map((item) => {
|
||||
const quantity = Number(item.quantity);
|
||||
const unitPrice = Number(item.unitPrice);
|
||||
return {
|
||||
id: item.id,
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity,
|
||||
unitPrice,
|
||||
currency: item.currency,
|
||||
position: item.position,
|
||||
lineTotal: quantity * unitPrice,
|
||||
};
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toPipelineDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
stages: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
isWon: boolean;
|
||||
isLost: boolean;
|
||||
}>;
|
||||
}): PipelineDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: row.isDefault,
|
||||
stages: row.stages.map(
|
||||
(s): PipelineStageDto => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
position: s.position,
|
||||
isWon: s.isWon,
|
||||
isLost: s.isLost,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PortalRequestOtpDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(20)
|
||||
mobile!: string;
|
||||
}
|
||||
|
||||
export class PortalVerifyOtpDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(20)
|
||||
mobile!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
@MaxLength(8)
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class PortalCreateTicketDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
subject!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export class PortalCreateMessageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(8000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
export class PortalCsatDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
csatScore!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
csatComment?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
CanActivate,
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
export type PortalActor = {
|
||||
personId: string;
|
||||
organizationId: string;
|
||||
mobile: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PortalAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
headers: { authorization?: string };
|
||||
portalActor?: PortalActor;
|
||||
}>();
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('ورود پرتال لازم است');
|
||||
}
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const payload = this.jwt.verify<{
|
||||
sub: string;
|
||||
organizationId: string;
|
||||
typ?: string;
|
||||
mobile?: string;
|
||||
}>(token, {
|
||||
secret: this.config.get<string>('JWT_SECRET') ?? 'dev-secret',
|
||||
});
|
||||
if (payload.typ !== 'portal' || !payload.sub || !payload.organizationId) {
|
||||
throw new UnauthorizedException('توکن پرتال نامعتبر است');
|
||||
}
|
||||
req.portalActor = {
|
||||
personId: payload.sub,
|
||||
organizationId: payload.organizationId,
|
||||
mobile: payload.mobile ?? '',
|
||||
};
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('نشست پرتال منقضی یا نامعتبر است');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CurrentPortal = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): PortalActor => {
|
||||
const req = ctx.switchToHttp().getRequest<{ portalActor?: PortalActor }>();
|
||||
if (!req.portalActor) {
|
||||
throw new UnauthorizedException('ورود پرتال لازم است');
|
||||
}
|
||||
return req.portalActor;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PortalCreateMessageDto,
|
||||
PortalCreateTicketDto,
|
||||
PortalCsatDto,
|
||||
PortalRequestOtpDto,
|
||||
PortalVerifyOtpDto,
|
||||
} from './dto';
|
||||
import { CurrentPortal, PortalAuthGuard, type PortalActor } from './portal-auth';
|
||||
import { PortalService } from './portal.service';
|
||||
|
||||
@Controller('public/portal/:slug')
|
||||
export class PortalController {
|
||||
constructor(private readonly portalService: PortalService) {}
|
||||
|
||||
@Get()
|
||||
info(@Param('slug') slug: string) {
|
||||
return this.portalService.getOrgInfo(slug);
|
||||
}
|
||||
|
||||
@Get('faq')
|
||||
faq(@Param('slug') slug: string) {
|
||||
return this.portalService.listFaq(slug);
|
||||
}
|
||||
|
||||
@Post('otp/request')
|
||||
requestOtp(@Param('slug') slug: string, @Body() dto: PortalRequestOtpDto) {
|
||||
return this.portalService.requestOtp(slug, dto.mobile);
|
||||
}
|
||||
|
||||
@Post('otp/verify')
|
||||
verifyOtp(@Param('slug') slug: string, @Body() dto: PortalVerifyOtpDto) {
|
||||
return this.portalService.verifyOtp(slug, dto.mobile, dto.code);
|
||||
}
|
||||
|
||||
@Get('tickets')
|
||||
@UseGuards(PortalAuthGuard)
|
||||
listTickets(@CurrentPortal() actor: PortalActor) {
|
||||
return this.portalService.listTickets(actor);
|
||||
}
|
||||
|
||||
@Get('tickets/:id')
|
||||
@UseGuards(PortalAuthGuard)
|
||||
getTicket(@CurrentPortal() actor: PortalActor, @Param('id') id: string) {
|
||||
return this.portalService.getTicket(actor, id);
|
||||
}
|
||||
|
||||
@Post('tickets')
|
||||
@UseGuards(PortalAuthGuard)
|
||||
createTicket(
|
||||
@CurrentPortal() actor: PortalActor,
|
||||
@Body() dto: PortalCreateTicketDto,
|
||||
) {
|
||||
return this.portalService.createTicket(actor, dto);
|
||||
}
|
||||
|
||||
@Post('tickets/:id/messages')
|
||||
@UseGuards(PortalAuthGuard)
|
||||
addMessage(
|
||||
@CurrentPortal() actor: PortalActor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: PortalCreateMessageDto,
|
||||
) {
|
||||
return this.portalService.addMessage(actor, id, dto);
|
||||
}
|
||||
|
||||
@Patch('tickets/:id/csat')
|
||||
@UseGuards(PortalAuthGuard)
|
||||
csat(
|
||||
@CurrentPortal() actor: PortalActor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: PortalCsatDto,
|
||||
) {
|
||||
return this.portalService.submitCsat(actor, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { PortalController } from './portal.controller';
|
||||
import { PortalService } from './portal.service';
|
||||
import { PortalAuthGuard } from './portal-auth';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [PortalController],
|
||||
providers: [PortalService, PortalAuthGuard, SmsService],
|
||||
})
|
||||
export class PortalModule {}
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type {
|
||||
FaqArticleDto,
|
||||
PortalCreateMessageRequest,
|
||||
PortalCreateTicketRequest,
|
||||
PortalCsatRequest,
|
||||
PortalLoginResponse,
|
||||
PortalOrgInfoDto,
|
||||
PortalRequestOtpResponse,
|
||||
PortalTicketDto,
|
||||
TicketPriority,
|
||||
} from '@mento/shared';
|
||||
import { TICKET_SLA_HOURS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import type { PortalActor } from './portal-auth';
|
||||
|
||||
const OTP_TTL_SECONDS = 5 * 60;
|
||||
const OTP_LENGTH = 6;
|
||||
|
||||
@Injectable()
|
||||
export class PortalService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> {
|
||||
const org = await this.findOrg(slug);
|
||||
return { name: org.name, slug: org.slug };
|
||||
}
|
||||
|
||||
async listFaq(slug: string): Promise<FaqArticleDto[]> {
|
||||
const org = await this.findOrg(slug);
|
||||
const rows = await this.prisma.faqArticle.findMany({
|
||||
where: { organizationId: org.id, isPublished: true },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
question: row.question,
|
||||
answer: row.answer,
|
||||
category: row.category,
|
||||
isPublished: row.isPublished,
|
||||
position: row.position,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async requestOtp(
|
||||
slug: string,
|
||||
mobileRaw: string,
|
||||
): Promise<PortalRequestOtpResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const recent = await this.prisma.portalOtp.count({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
mobile,
|
||||
createdAt: { gte: new Date(Date.now() - 60 * 60 * 1000) },
|
||||
},
|
||||
});
|
||||
if (recent >= 8) {
|
||||
throw new BadRequestException(
|
||||
'درخواست زیاد است؛ کمی بعد دوباره تلاش کنید',
|
||||
);
|
||||
}
|
||||
|
||||
const code = String(
|
||||
Math.floor(10 ** (OTP_LENGTH - 1) + Math.random() * 9 * 10 ** (OTP_LENGTH - 1)),
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_SECONDS * 1000);
|
||||
await this.prisma.portalOtp.create({
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
mobile,
|
||||
code,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const returnOtp =
|
||||
this.config.get<string>('PORTAL_SHOW_OTP') === 'true' ||
|
||||
this.config.get<string>('AUTH_RETURN_OTP') === 'true' ||
|
||||
(this.config.get<string>('NODE_ENV') !== 'production' &&
|
||||
this.config.get<string>('PORTAL_SHOW_OTP') !== 'false');
|
||||
|
||||
if (this.sms.isConfigured()) {
|
||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
||||
if (!sent && !returnOtp) {
|
||||
throw new BadRequestException(
|
||||
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
||||
);
|
||||
}
|
||||
} else if (!returnOtp) {
|
||||
throw new BadRequestException('سامانه پیامک پیکربندی نشده است');
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[portal-otp] ${org.slug} ${mobile} => ${code}`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
expiresInSeconds: OTP_TTL_SECONDS,
|
||||
...(returnOtp ? { devCode: code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOtp(
|
||||
slug: string,
|
||||
mobileRaw: string,
|
||||
codeRaw: string,
|
||||
): Promise<PortalLoginResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const code = codeRaw.trim();
|
||||
if (!mobile || !code) {
|
||||
throw new BadRequestException('موبایل یا کد ناقص است');
|
||||
}
|
||||
|
||||
const otp = await this.prisma.portalOtp.findFirst({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
mobile,
|
||||
code,
|
||||
consumedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!otp) {
|
||||
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
||||
}
|
||||
|
||||
await this.prisma.portalOtp.update({
|
||||
where: { id: otp.id },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: { organizationId: org.id, mobile },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!person) {
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
firstName: 'مشتری',
|
||||
lastName: mobile.slice(-4),
|
||||
mobile,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const accessToken = await this.jwt.signAsync(
|
||||
{
|
||||
sub: person.id,
|
||||
organizationId: org.id,
|
||||
typ: 'portal',
|
||||
mobile,
|
||||
},
|
||||
{
|
||||
secret: this.config.get<string>('JWT_SECRET') ?? 'dev-secret',
|
||||
expiresIn: '7d',
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
person: {
|
||||
id: person.id,
|
||||
fullName: `${person.firstName} ${person.lastName}`.trim(),
|
||||
mobile: person.mobile,
|
||||
},
|
||||
organization: { name: org.name, slug: org.slug },
|
||||
};
|
||||
}
|
||||
|
||||
async listTickets(actor: PortalActor): Promise<PortalTicketDto[]> {
|
||||
const rows = await this.prisma.ticket.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
personId: actor.personId,
|
||||
},
|
||||
include: {
|
||||
messages: {
|
||||
where: { isInternal: false },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { author: { select: { id: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
return rows.map((row) => this.toPortalTicket(row, actor.personId));
|
||||
}
|
||||
|
||||
async getTicket(actor: PortalActor, id: string): Promise<PortalTicketDto> {
|
||||
const row = await this.findTicket(actor, id);
|
||||
return this.toPortalTicket(row, actor.personId);
|
||||
}
|
||||
|
||||
async createTicket(
|
||||
actor: PortalActor,
|
||||
dto: PortalCreateTicketRequest,
|
||||
): Promise<PortalTicketDto> {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: actor.personId, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!person) throw new NotFoundException('پرونده مشتری پیدا نشد');
|
||||
|
||||
const count = await this.prisma.ticket.count({
|
||||
where: { organizationId: actor.organizationId },
|
||||
});
|
||||
const number = `TK-${String(count + 1).padStart(4, '0')}`;
|
||||
const priority = (dto.priority as TicketPriority) || 'normal';
|
||||
const createdAt = new Date();
|
||||
const hours = TICKET_SLA_HOURS[priority] ?? TICKET_SLA_HOURS.normal;
|
||||
|
||||
const owner =
|
||||
(await this.prisma.user.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
role: { in: ['owner', 'admin', 'support'] },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) ?? null;
|
||||
|
||||
const row = await this.prisma.ticket.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
number,
|
||||
subject: dto.subject.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
status: 'open',
|
||||
priority: priority as never,
|
||||
personId: person.id,
|
||||
companyId: person.companyId,
|
||||
assigneeId: owner?.id ?? null,
|
||||
reporterId: null,
|
||||
createdAt,
|
||||
slaDueAt: new Date(createdAt.getTime() + hours * 60 * 60 * 1000),
|
||||
...(dto.description?.trim()
|
||||
? {
|
||||
messages: {
|
||||
create: {
|
||||
authorId: null,
|
||||
body: dto.description.trim(),
|
||||
isInternal: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
messages: {
|
||||
where: { isInternal: false },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { author: { select: { id: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.toPortalTicket(row, actor.personId);
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
actor: PortalActor,
|
||||
ticketId: string,
|
||||
dto: PortalCreateMessageRequest,
|
||||
): Promise<PortalTicketDto> {
|
||||
await this.findTicket(actor, ticketId);
|
||||
await this.prisma.ticketMessage.create({
|
||||
data: {
|
||||
ticketId,
|
||||
authorId: null,
|
||||
body: dto.body.trim(),
|
||||
isInternal: false,
|
||||
},
|
||||
});
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: {
|
||||
updatedAt: new Date(),
|
||||
status: 'waiting',
|
||||
},
|
||||
});
|
||||
return this.getTicket(actor, ticketId);
|
||||
}
|
||||
|
||||
async submitCsat(
|
||||
actor: PortalActor,
|
||||
ticketId: string,
|
||||
dto: PortalCsatRequest,
|
||||
): Promise<PortalTicketDto> {
|
||||
const existing = await this.findTicket(actor, ticketId);
|
||||
if (existing.status !== 'resolved' && existing.status !== 'closed') {
|
||||
throw new BadRequestException(
|
||||
'ثبت رضایت فقط پس از حل یا بستن تیکت ممکن است',
|
||||
);
|
||||
}
|
||||
if (dto.csatScore < 1 || dto.csatScore > 5) {
|
||||
throw new BadRequestException('امتیاز باید بین ۱ تا ۵ باشد');
|
||||
}
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticketId },
|
||||
data: {
|
||||
csatScore: dto.csatScore,
|
||||
csatComment: dto.csatComment?.trim() || null,
|
||||
csatAt: new Date(),
|
||||
},
|
||||
});
|
||||
return this.getTicket(actor, ticketId);
|
||||
}
|
||||
|
||||
private async findOrg(slug: string) {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true, name: true, slug: true },
|
||||
});
|
||||
if (!org) throw new NotFoundException('سازمان پیدا نشد');
|
||||
return org;
|
||||
}
|
||||
|
||||
private async findTicket(actor: PortalActor, id: string) {
|
||||
const row = await this.prisma.ticket.findFirst({
|
||||
where: {
|
||||
id,
|
||||
organizationId: actor.organizationId,
|
||||
personId: actor.personId,
|
||||
},
|
||||
include: {
|
||||
messages: {
|
||||
where: { isInternal: false },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { author: { select: { id: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('تیکت پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private toPortalTicket(
|
||||
row: {
|
||||
id: string;
|
||||
number: string;
|
||||
subject: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
resolvedAt: Date | null;
|
||||
csatScore: number | null;
|
||||
csatComment: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
author: { id: string } | null;
|
||||
}>;
|
||||
},
|
||||
personId: string,
|
||||
): PortalTicketDto {
|
||||
void personId;
|
||||
return {
|
||||
id: row.id,
|
||||
number: row.number,
|
||||
subject: row.subject,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
resolvedAt: row.resolvedAt?.toISOString() ?? null,
|
||||
csatScore: row.csatScore,
|
||||
csatComment: row.csatComment,
|
||||
messages: row.messages.map((m) => ({
|
||||
id: m.id,
|
||||
body: m.body,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
fromStaff: Boolean(m.author),
|
||||
})),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** نرمالسازی موبایل ایران به 09xxxxxxxxx */
|
||||
export function normalizeMobile(raw: string): string | null {
|
||||
let s = raw.replace(/[\s\-()]/g, '');
|
||||
if (s.startsWith('+98')) s = `0${s.slice(3)}`;
|
||||
if (s.startsWith('98') && s.length === 12) s = `0${s.slice(2)}`;
|
||||
if (/^09\d{9}$/.test(s)) return s;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sku?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sku?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ListProductsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
activeOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateProductDto, ListProductsQueryDto, UpdateProductDto } from './dto';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Controller('products')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class ProductsController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListProductsQueryDto,
|
||||
) {
|
||||
return this.productsService.list(user.organizationId, {
|
||||
activeOnly: query.activeOnly,
|
||||
q: query.q,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateProductDto,
|
||||
) {
|
||||
return this.productsService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
) {
|
||||
return this.productsService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.productsService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user