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:
2026-08-29 21:06:30 +03:30
co-authored by Cursor
commit 29a040c21d
195 changed files with 39430 additions and 0 deletions
+23
View File
@@ -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
View File
@@ -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/
+123
View File
@@ -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 کنید.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+48
View File
@@ -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"
+738
View File
@@ -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 };
}
+149
View File
@@ -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;
}
+54
View File
@@ -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 {}
+26
View File
@@ -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);
}
}
+28
View File
@@ -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 {}
+121
View File
@@ -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;
},
);
+27
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+32
View File
@@ -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,
};
}
}
+6
View File
@@ -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);
+31
View File
@@ -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;
}
}
+54
View File
@@ -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);
}
+14
View File
@@ -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;
}
+70
View File
@@ -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;
+275
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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 };
}
}
+238
View File
@@ -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;
}
}
+13
View File
@@ -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 {}
+229
View File
@@ -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;
}
+19
View File
@@ -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 };
}
}
+238
View File
@@ -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(),
};
}
}
+107
View File
@@ -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;
}
+87
View File
@@ -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;
}
+77
View File
@@ -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 };
}
}
+12
View File
@@ -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 {}
+121
View File
@@ -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;
}
+14
View File
@@ -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(),
};
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+257
View File
@@ -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,
);
}
}
+10
View File
@@ -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 {}
+641
View File
@@ -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;
}
+155
View File
@@ -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 ?? {});
}
}
+30
View File
@@ -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();
+170
View File
@@ -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;
}
+147
View File
@@ -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 };
}
}
+10
View File
@@ -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 {}
+502
View File
@@ -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(),
};
}
}
+44
View File
@@ -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 };
}
}
+398
View File
@@ -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,
);
}
+218
View File
@@ -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 {}
+262
View File
@@ -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,
}),
),
};
}
}
+65
View File
@@ -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;
}
+66
View File
@@ -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;
},
);
+84
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+410
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+13
View File
@@ -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();
}
}
+89
View File
@@ -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