commit 29a040c21d50807443fa36bbe9abc2a63164bbeb Author: Mahdi Date: Sat Aug 29 21:06:30 2026 +0330 Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b50d788 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a81a2b --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..720751b --- /dev/null +++ b/README.md @@ -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 کنید. \ No newline at end of file diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..094c9a9 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/prisma/migrations/20260825113915_init/migration.sql b/apps/api/prisma/migrations/20260825113915_init/migration.sql new file mode 100644 index 0000000..0d72be3 --- /dev/null +++ b/apps/api/prisma/migrations/20260825113915_init/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260825150131_contacts_people_companies/migration.sql b/apps/api/prisma/migrations/20260825150131_contacts_people_companies/migration.sql new file mode 100644 index 0000000..95470c2 --- /dev/null +++ b/apps/api/prisma/migrations/20260825150131_contacts_people_companies/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260825152250_pipeline_deals/migration.sql b/apps/api/prisma/migrations/20260825152250_pipeline_deals/migration.sql new file mode 100644 index 0000000..18f3058 --- /dev/null +++ b/apps/api/prisma/migrations/20260825152250_pipeline_deals/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260825162528_activities/migration.sql b/apps/api/prisma/migrations/20260825162528_activities/migration.sql new file mode 100644 index 0000000..7a35be7 --- /dev/null +++ b/apps/api/prisma/migrations/20260825162528_activities/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260825164154_smart_notes/migration.sql b/apps/api/prisma/migrations/20260825164154_smart_notes/migration.sql new file mode 100644 index 0000000..448cfeb --- /dev/null +++ b/apps/api/prisma/migrations/20260825164154_smart_notes/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260826125212_custom_fields/migration.sql b/apps/api/prisma/migrations/20260826125212_custom_fields/migration.sql new file mode 100644 index 0000000..476d062 --- /dev/null +++ b/apps/api/prisma/migrations/20260826125212_custom_fields/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260827070835_deal_enrichment/migration.sql b/apps/api/prisma/migrations/20260827070835_deal_enrichment/migration.sql new file mode 100644 index 0000000..32f4548 --- /dev/null +++ b/apps/api/prisma/migrations/20260827070835_deal_enrichment/migration.sql @@ -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[]; diff --git a/apps/api/prisma/migrations/20260827104236_deal_products/migration.sql b/apps/api/prisma/migrations/20260827104236_deal_products/migration.sql new file mode 100644 index 0000000..f93f9f4 --- /dev/null +++ b/apps/api/prisma/migrations/20260827104236_deal_products/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260827112207_in_app_notifications/migration.sql b/apps/api/prisma/migrations/20260827112207_in_app_notifications/migration.sql new file mode 100644 index 0000000..5291001 --- /dev/null +++ b/apps/api/prisma/migrations/20260827112207_in_app_notifications/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260827150654_note_types/migration.sql b/apps/api/prisma/migrations/20260827150654_note_types/migration.sql new file mode 100644 index 0000000..7465cc0 --- /dev/null +++ b/apps/api/prisma/migrations/20260827150654_note_types/migration.sql @@ -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"); diff --git a/apps/api/prisma/migrations/20260829104931_note_attachments/migration.sql b/apps/api/prisma/migrations/20260829104931_note_attachments/migration.sql new file mode 100644 index 0000000..6c84aa5 --- /dev/null +++ b/apps/api/prisma/migrations/20260829104931_note_attachments/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829124204_lead_forms/migration.sql b/apps/api/prisma/migrations/20260829124204_lead_forms/migration.sql new file mode 100644 index 0000000..311e402 --- /dev/null +++ b/apps/api/prisma/migrations/20260829124204_lead_forms/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829130809_quotes/migration.sql b/apps/api/prisma/migrations/20260829130809_quotes/migration.sql new file mode 100644 index 0000000..ead4dbb --- /dev/null +++ b/apps/api/prisma/migrations/20260829130809_quotes/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829132602_invoices_payments/migration.sql b/apps/api/prisma/migrations/20260829132602_invoices_payments/migration.sql new file mode 100644 index 0000000..5a639be --- /dev/null +++ b/apps/api/prisma/migrations/20260829132602_invoices_payments/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829141155_person_company_owner/migration.sql b/apps/api/prisma/migrations/20260829141155_person_company_owner/migration.sql new file mode 100644 index 0000000..216abd0 --- /dev/null +++ b/apps/api/prisma/migrations/20260829141155_person_company_owner/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829144441_sales_goals/migration.sql b/apps/api/prisma/migrations/20260829144441_sales_goals/migration.sql new file mode 100644 index 0000000..7ef7f08 --- /dev/null +++ b/apps/api/prisma/migrations/20260829144441_sales_goals/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829150628_multi_currency/migration.sql b/apps/api/prisma/migrations/20260829150628_multi_currency/migration.sql new file mode 100644 index 0000000..641684e --- /dev/null +++ b/apps/api/prisma/migrations/20260829150628_multi_currency/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829152302_tickets/migration.sql b/apps/api/prisma/migrations/20260829152302_tickets/migration.sql new file mode 100644 index 0000000..62c6b74 --- /dev/null +++ b/apps/api/prisma/migrations/20260829152302_tickets/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829154336_ticket_sla_faq_csat/migration.sql b/apps/api/prisma/migrations/20260829154336_ticket_sla_faq_csat/migration.sql new file mode 100644 index 0000000..1bc3da9 --- /dev/null +++ b/apps/api/prisma/migrations/20260829154336_ticket_sla_faq_csat/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260829165622_portal_otp_and_reports/migration.sql b/apps/api/prisma/migrations/20260829165622_portal_otp_and_reports/migration.sql new file mode 100644 index 0000000..eddf903 --- /dev/null +++ b/apps/api/prisma/migrations/20260829165622_portal_otp_and_reports/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -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" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..1fd12f4 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..8de69b9 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,1110 @@ +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +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; + +async function main() { + const email = 'owner@demo.local'; + let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } }); + if (!org) { + org = await prisma.organization.findUnique({ where: { slug: 'demo-ment' } }); + if (org) { + org = await prisma.organization.update({ + where: { id: org.id }, + data: { slug: 'demo-mento', name: 'سازمان نمونه منتو' }, + }); + console.log('Renamed demo org slug to demo-mento'); + } + } + + if (!org) { + const passwordHash = await bcrypt.hash('demo1234', 10); + org = await prisma.organization.create({ + data: { + name: 'سازمان نمونه منتو', + slug: 'demo-mento', + baseCurrency: 'IRR', + enabledCurrencies: ['IRR', 'USD', 'EUR'], + users: { + create: { + email, + fullName: 'مدیر نمونه', + passwordHash, + role: 'owner', + }, + }, + }, + }); + console.log('Seeded organization:', org.slug); + console.log('Login:', email, '/ demo1234'); + } else { + console.log('Organization exists:', org.slug); + } + + const owner = await prisma.user.findFirst({ + where: { organizationId: org.id, email }, + }); + + // چندارزی نمونه + await prisma.organization.update({ + where: { id: org.id }, + data: { + baseCurrency: 'IRR', + enabledCurrencies: ['IRR', 'USD', 'EUR'], + }, + }); + for (const row of [ + { currency: 'USD', rateToBase: 920_000 }, + { currency: 'EUR', rateToBase: 1_000_000 }, + ] as const) { + await prisma.exchangeRate.upsert({ + where: { + organizationId_currency: { + organizationId: org.id, + currency: row.currency, + }, + }, + create: { + organizationId: org.id, + currency: row.currency, + rateToBase: row.rateToBase, + }, + update: { rateToBase: row.rateToBase }, + }); + } + + const salesEmail = 'sales@demo.local'; + const existingSales = await prisma.user.findFirst({ + where: { organizationId: org.id, email: salesEmail }, + }); + if (!existingSales) { + await prisma.user.create({ + data: { + organizationId: org.id, + email: salesEmail, + fullName: 'کارشناس فروش نمونه', + passwordHash: await bcrypt.hash('demo1234', 10), + role: 'sales', + }, + }); + console.log('Seeded sales user:', salesEmail, '/ demo1234'); + } + + const adminEmail = 'admin@demo.local'; + const existingAdmin = await prisma.user.findFirst({ + where: { organizationId: org.id, email: adminEmail }, + }); + if (!existingAdmin) { + await prisma.user.create({ + data: { + organizationId: org.id, + email: adminEmail, + fullName: 'مدیر سازمانی نمونه', + passwordHash: await bcrypt.hash('demo1234', 10), + role: 'admin', + }, + }); + console.log('Seeded admin user:', adminEmail, '/ demo1234'); + } + + let peopleCount = await prisma.person.count({ + where: { organizationId: org.id }, + }); + + if (peopleCount === 0) { + const acme = await prisma.company.create({ + data: { + organizationId: org.id, + name: 'شرکت نمونه آکمه', + phone: '02188776655', + email: 'info@acme.example', + address: 'تهران، خیابان ولیعصر', + notes: 'مشتری بالقوه سازمانی', + }, + }); + + const nova = await prisma.company.create({ + data: { + organizationId: org.id, + name: 'نوا تجارت', + phone: '03132221100', + email: 'hello@nova.example', + }, + }); + + await prisma.person.createMany({ + data: [ + { + organizationId: org.id, + companyId: acme.id, + firstName: 'سارا', + lastName: 'محمدی', + email: 'sara@acme.example', + mobile: '09121234567', + position: 'مدیر خرید', + }, + { + organizationId: org.id, + companyId: acme.id, + firstName: 'علی', + lastName: 'رضایی', + email: 'ali@acme.example', + mobile: '09129876543', + position: 'کارشناس فروش', + }, + { + organizationId: org.id, + companyId: nova.id, + firstName: 'مریم', + lastName: 'کریمی', + email: 'maryam@nova.example', + mobile: '09351234567', + position: 'مدیرعامل', + }, + { + organizationId: org.id, + firstName: 'حسین', + lastName: 'نوری', + email: 'hosein.nouri@example.com', + mobile: '09121112233', + notes: 'سرنخ فردی بدون شرکت', + }, + ], + }); + console.log('Seeded sample companies and people'); + peopleCount = 4; + } else { + console.log('Contacts already seeded'); + } + + let pipeline = await prisma.pipeline.findFirst({ + where: { organizationId: org.id, isDefault: true }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + + if (!pipeline) { + pipeline = await prisma.pipeline.create({ + data: { + organizationId: org.id, + 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' } } }, + }); + console.log('Seeded default pipeline'); + } else { + console.log('Pipeline already seeded'); + } + + const servicesPipeline = await prisma.pipeline.findFirst({ + where: { organizationId: org.id, name: 'کاریز خدمات' }, + }); + if (!servicesPipeline) { + await prisma.pipeline.create({ + data: { + organizationId: org.id, + name: 'کاریز خدمات', + isDefault: false, + stages: { + create: DEFAULT_STAGES.map((s, index) => ({ + name: s.name, + position: index, + isWon: s.isWon, + isLost: s.isLost, + })), + }, + }, + }); + console.log('Seeded services pipeline'); + } else { + console.log('Services pipeline already seeded'); + } + + const dealsCount = await prisma.deal.count({ + where: { organizationId: org.id }, + }); + + if (dealsCount === 0) { + const people = await prisma.person.findMany({ + where: { organizationId: org.id }, + include: { company: true }, + orderBy: { createdAt: 'asc' }, + }); + const stages = pipeline.stages; + const lead = stages[0]; + const call = stages[1]; + const proposal = stages[2]; + const negotiation = stages[3]; + + if (!lead || !call || !proposal || !negotiation || people.length === 0) { + console.log('Skip deals seed: missing stages or people'); + } else { + await prisma.deal.createMany({ + data: [ + { + organizationId: org.id, + pipelineId: pipeline.id, + stageId: lead.id, + title: 'فروش بسته سازمانی آکمه', + amount: 120_000_000, + currency: 'IRR', + source: 'معرفی', + tags: ['سازمانی', 'اولویت‌بالا'], + personId: people[0]?.id, + companyId: people[0]?.companyId, + ownerId: owner?.id, + position: 0, + }, + { + organizationId: org.id, + pipelineId: pipeline.id, + stageId: call.id, + title: 'تمدید قرارداد نوا', + amount: 45_000_000, + currency: 'IRR', + source: 'وب‌سایت', + tags: ['تمدید'], + isStale: true, + personId: people[2]?.id, + companyId: people[2]?.companyId, + ownerId: owner?.id, + position: 0, + }, + { + organizationId: org.id, + pipelineId: pipeline.id, + stageId: proposal.id, + title: 'مشاوره فردی حسین نوری', + amount: 8_500_000, + currency: 'IRR', + source: 'تماس ورودی', + tags: ['خرد'], + personId: people[3]?.id, + ownerId: owner?.id, + position: 0, + }, + { + organizationId: org.id, + pipelineId: pipeline.id, + stageId: negotiation.id, + title: 'ارتقای لایسنس آکمه', + amount: 70_000_000, + currency: 'IRR', + tags: ['ارتقاء'], + isRotten: true, + personId: people[1]?.id, + companyId: people[1]?.companyId, + ownerId: owner?.id, + position: 0, + }, + ], + }); + console.log('Seeded sample deals'); + } + } else { + console.log('Deals already seeded'); + const sample = await prisma.deal.findFirst({ + where: { organizationId: org.id, title: 'فروش بسته سازمانی آکمه' }, + }); + if (sample && sample.tags.length === 0) { + await prisma.deal.update({ + where: { id: sample.id }, + data: { tags: ['سازمانی', 'اولویت‌بالا'] }, + }); + const stale = await prisma.deal.findFirst({ + where: { organizationId: org.id, title: 'تمدید قرارداد نوا' }, + }); + if (stale) { + await prisma.deal.update({ + where: { id: stale.id }, + data: { tags: ['تمدید'], isStale: true }, + }); + } + const rotten = await prisma.deal.findFirst({ + where: { organizationId: org.id, title: 'ارتقای لایسنس آکمه' }, + }); + if (rotten) { + await prisma.deal.update({ + where: { id: rotten.id }, + data: { tags: ['ارتقاء'], isRotten: true }, + }); + } + console.log('Enriched sample deals with tags/stale/rotten'); + } + } + + const activitiesCount = await prisma.activity.count({ + where: { organizationId: org.id }, + }); + + if (activitiesCount === 0) { + const peopleForAct = await prisma.person.findMany({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + const dealsForAct = await prisma.deal.findMany({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + + const now = new Date(); + const todayMorning = new Date(now); + todayMorning.setHours(10, 0, 0, 0); + const todayAfternoon = new Date(now); + todayAfternoon.setHours(15, 30, 0, 0); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(11, 0, 0, 0); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(12, 0, 0, 0); + + await prisma.activity.createMany({ + data: [ + { + organizationId: org.id, + type: 'call', + title: 'تماس پیگیری با سارا محمدی', + description: 'وضعیت پیشنهاد بسته سازمانی', + dueAt: todayMorning, + personId: peopleForAct[0]?.id, + dealId: dealsForAct[0]?.id, + assigneeId: owner?.id, + }, + { + organizationId: org.id, + type: 'meeting', + title: 'جلسه دمو با نوا تجارت', + dueAt: todayAfternoon, + personId: peopleForAct[2]?.id, + dealId: dealsForAct[1]?.id, + assigneeId: owner?.id, + }, + { + organizationId: org.id, + type: 'task', + title: 'ارسال پیشنهاد قیمت به حسین نوری', + dueAt: yesterday, + personId: peopleForAct[3]?.id, + dealId: dealsForAct[2]?.id, + assigneeId: owner?.id, + }, + { + organizationId: org.id, + type: 'task', + title: 'آماده‌سازی قرارداد ارتقا آکمه', + dueAt: tomorrow, + personId: peopleForAct[1]?.id, + dealId: dealsForAct[3]?.id, + assigneeId: owner?.id, + }, + ], + }); + console.log('Seeded sample activities'); + } else { + console.log('Activities already seeded'); + } + + const notesCount = await prisma.note.count({ + where: { organizationId: org.id }, + }); + if (notesCount === 0) { + const people = await prisma.person.findMany({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + const deals = await prisma.deal.findMany({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + + const reminder = new Date(); + reminder.setDate(reminder.getDate() + 2); + reminder.setHours(9, 0, 0, 0); + + await prisma.note.createMany({ + data: [ + { + organizationId: org.id, + authorId: owner?.id, + title: 'نکته خریدار آکمه', + body: 'بودجه امسال تا پایان مهر باید مصرف شود. تصمیم‌گیر اصلی سارا محمدی است.', + noteType: 'important', + pinned: true, + color: 'mint', + tags: ['بودجه', 'تصمیم‌گیر'], + personId: people[0]?.id, + dealId: deals[0]?.id, + reminderAt: reminder, + }, + { + organizationId: org.id, + authorId: owner?.id, + body: 'در دمو نوا روی گزارش کاریز خیلی حساس بودند. نسخه چاپی PDF را آماده کنیم.', + noteType: 'call_result', + color: 'sky', + tags: ['دمو'], + personId: people[2]?.id, + dealId: deals[1]?.id, + }, + { + organizationId: org.id, + authorId: owner?.id, + title: 'یادداشت آزاد', + body: 'حسین نوری ترجیح می‌دهد تماس‌ها عصر باشد.', + noteType: 'next_action', + color: 'sand', + tags: ['زمان‌بندی'], + personId: people[3]?.id, + }, + ], + }); + console.log('Seeded sample notes'); + } else { + await prisma.note.updateMany({ + where: { organizationId: org.id, title: 'نکته خریدار آکمه' }, + data: { noteType: 'important' }, + }); + await prisma.note.updateMany({ + where: { + organizationId: org.id, + body: { + contains: 'در دمو نوا', + }, + }, + data: { noteType: 'call_result' }, + }); + await prisma.note.updateMany({ + where: { organizationId: org.id, title: 'یادداشت آزاد' }, + data: { noteType: 'next_action' }, + }); + console.log('Notes already seeded'); + } + + const customFieldCount = await prisma.customFieldDefinition.count({ + where: { organizationId: org.id }, + }); + if (customFieldCount === 0) { + await prisma.customFieldDefinition.createMany({ + data: [ + { + organizationId: org.id, + entityType: 'person', + key: 'preferred_channel', + label: 'کانال ترجیحی', + fieldType: 'select', + options: ['تلفن', 'واتساپ', 'ایمیل'], + required: false, + position: 0, + }, + { + organizationId: org.id, + entityType: 'company', + key: 'industry', + label: 'صنعت', + fieldType: 'text', + required: false, + position: 0, + }, + { + organizationId: org.id, + entityType: 'deal', + key: 'priority', + label: 'اولویت', + fieldType: 'select', + options: ['کم', 'متوسط', 'زیاد'], + required: false, + position: 0, + }, + ], + }); + + const person = await prisma.person.findFirst({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + if (person) { + await prisma.person.update({ + where: { id: person.id }, + data: { customFields: { preferred_channel: 'واتساپ' } }, + }); + } + console.log('Seeded sample custom fields'); + } else { + console.log('Custom fields already seeded'); + } + + const productCount = await prisma.product.count({ + where: { organizationId: org.id }, + }); + if (productCount === 0) { + const created = await prisma.product.createMany({ + data: [ + { + organizationId: org.id, + name: 'لایسنس سازمانی منتو', + sku: 'MENTO-ORG', + unitPrice: 80_000_000, + currency: 'IRR', + }, + { + organizationId: org.id, + name: 'پشتیبانی سالانه', + sku: 'MENTO-SUP', + unitPrice: 20_000_000, + currency: 'IRR', + }, + { + organizationId: org.id, + name: 'مشاوره پیاده‌سازی', + sku: 'MENTO-IMP', + unitPrice: 15_000_000, + currency: 'IRR', + }, + ], + }); + void created; + + const products = await prisma.product.findMany({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + const deal = await prisma.deal.findFirst({ + where: { organizationId: org.id, title: 'فروش بسته سازمانی آکمه' }, + }); + if (deal && products[0] && products[1]) { + await prisma.dealLineItem.createMany({ + data: [ + { + dealId: deal.id, + productId: products[0].id, + name: products[0].name, + quantity: 1, + unitPrice: products[0].unitPrice ?? 0, + currency: 'IRR', + position: 0, + }, + { + dealId: deal.id, + productId: products[1].id, + name: products[1].name, + quantity: 1, + unitPrice: products[1].unitPrice ?? 0, + currency: 'IRR', + position: 1, + }, + ], + }); + await prisma.deal.update({ + where: { id: deal.id }, + data: { + amount: + Number(products[0].unitPrice ?? 0) + + Number(products[1].unitPrice ?? 0), + }, + }); + } + console.log('Seeded sample products'); + } else { + console.log('Products already seeded'); + } + + if (owner) { + const sampleDeal = await prisma.deal.findFirst({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + const sampleActivity = await prisma.activity.findFirst({ + where: { organizationId: org.id, assigneeId: owner.id }, + orderBy: { createdAt: 'asc' }, + }); + + const ensure = async (type: string, data: { + title: string; + body: string; + link: string; + entityType: string; + entityId: string | null; + readAt?: Date | null; + }) => { + const found = await prisma.notification.findFirst({ + where: { organizationId: org.id, userId: owner.id, type }, + select: { id: true }, + }); + if (found) return; + await prisma.notification.create({ + data: { + organizationId: org.id, + userId: owner.id, + type, + title: data.title, + body: data.body, + link: data.link, + entityType: data.entityType, + entityId: data.entityId, + readAt: data.readAt ?? null, + }, + }); + }; + + await ensure('deal_moved', { + title: 'جابه‌جایی معامله در کاریز', + body: sampleDeal + ? `«${sampleDeal.title}» به مرحله جدید منتقل شد` + : 'یک معامله جابه‌جا شد', + link: sampleDeal ? `/deals/${sampleDeal.id}` : '/pipeline', + entityType: 'deal', + entityId: sampleDeal?.id ?? null, + }); + await ensure('activity_assigned', { + title: 'فعالیت جدید برای شما', + body: sampleActivity?.title ?? 'پیگیری نمونه', + link: sampleActivity + ? `/activities/${sampleActivity.id}` + : '/activities', + entityType: 'activity', + entityId: sampleActivity?.id ?? null, + }); + await ensure('note_reminder', { + title: 'یادآوری یادداشت', + body: 'یادداشت نمونه با یادآور', + link: '/notes', + entityType: 'note', + entityId: null, + readAt: new Date(), + }); + console.log('Ensured sample notifications'); + } + + const leadFormCount = await prisma.leadForm.count({ + where: { organizationId: org.id }, + }); + if (leadFormCount === 0) { + const defaultPipeline = await prisma.pipeline.findFirst({ + where: { organizationId: org.id, isDefault: true }, + }); + const sales = await prisma.user.findFirst({ + where: { organizationId: org.id, email: 'sales@demo.local' }, + }); + await prisma.leadForm.create({ + data: { + organizationId: org.id, + name: 'فرم تماس سایت', + source: 'وب‌سایت', + pipelineId: defaultPipeline?.id, + thankYouMessage: 'پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.', + assigneeIds: [owner?.id, sales?.id].filter(Boolean) as string[], + fields: { + create: [ + { + 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, + }, + ], + }, + }, + }); + console.log('Seeded sample lead form'); + } + + const quoteCount = await prisma.quote.count({ where: { organizationId: org.id } }); + if (quoteCount === 0) { + const deal = await prisma.deal.findFirst({ + where: { organizationId: org.id }, + include: { lineItems: { orderBy: { position: 'asc' } } }, + orderBy: { createdAt: 'asc' }, + }); + const product = await prisma.product.findFirst({ + where: { organizationId: org.id, isActive: true }, + }); + if (deal) { + const validUntil = new Date(); + validUntil.setDate(validUntil.getDate() + 14); + await prisma.quote.create({ + data: { + organizationId: org.id, + number: 'PF-0001', + title: `پیش‌فاکتور — ${deal.title}`, + status: 'draft', + currency: deal.currency || 'IRR', + notes: 'پیش‌فاکتور نمونه', + validUntil, + personId: deal.personId, + companyId: deal.companyId, + dealId: deal.id, + ownerId: owner?.id ?? deal.ownerId, + lineItems: { + create: + deal.lineItems.length > 0 + ? deal.lineItems.map((item, index) => ({ + productId: item.productId, + name: item.name, + quantity: item.quantity, + unitPrice: item.unitPrice, + currency: item.currency, + position: index, + })) + : [ + { + productId: product?.id, + name: product?.name ?? 'خدمت مشاوره', + quantity: 1, + unitPrice: product?.unitPrice ?? 15000000, + currency: 'IRR', + position: 0, + }, + ], + }, + }, + }); + console.log('Seeded sample quote'); + } + } + + const invoiceCount = await prisma.invoice.count({ + where: { organizationId: org.id }, + }); + if (invoiceCount === 0) { + const quote = await prisma.quote.findFirst({ + where: { organizationId: org.id }, + include: { lineItems: { orderBy: { position: 'asc' } } }, + orderBy: { createdAt: 'asc' }, + }); + const deal = quote + ? null + : await prisma.deal.findFirst({ + where: { organizationId: org.id }, + include: { lineItems: { orderBy: { position: 'asc' } } }, + orderBy: { createdAt: 'asc' }, + }); + const source = quote ?? deal; + if (source) { + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 30); + const lineSource = source.lineItems; + const total = lineSource.reduce( + (sum, item) => sum + Number(item.quantity) * Number(item.unitPrice), + 0, + ); + const half = Math.round(total / 2); + const installment1 = new Date(); + installment1.setDate(installment1.getDate() + 15); + const installment2 = new Date(); + installment2.setDate(installment2.getDate() + 45); + + await prisma.invoice.create({ + data: { + organizationId: org.id, + number: 'FA-0001', + title: `فاکتور — ${source.title}`, + status: 'issued', + currency: source.currency || 'IRR', + notes: 'فاکتور نمونه با دو قسط', + issuedAt: new Date(), + dueDate, + personId: source.personId, + companyId: source.companyId, + dealId: quote ? quote.dealId : deal!.id, + quoteId: quote?.id ?? null, + ownerId: owner?.id ?? null, + lineItems: { + create: + lineSource.length > 0 + ? lineSource.map((item, index) => ({ + productId: item.productId, + name: item.name, + quantity: item.quantity, + unitPrice: item.unitPrice, + currency: item.currency, + position: index, + })) + : [ + { + name: 'خدمت مشاوره', + quantity: 1, + unitPrice: 15000000, + currency: 'IRR', + position: 0, + }, + ], + }, + installments: { + create: [ + { + position: 0, + dueDate: installment1, + amount: half || 10000000, + currency: 'IRR', + status: 'pending', + }, + { + position: 1, + dueDate: installment2, + amount: total - half || 10000000, + currency: 'IRR', + status: 'pending', + }, + ], + }, + }, + }); + console.log('Seeded sample invoice'); + } + } + + // دسترسی پرونده‌ای: بخشی از پرونده‌ها برای کارشناس فروش + const salesUser = await prisma.user.findFirst({ + where: { organizationId: org.id, email: salesEmail }, + }); + if (owner) { + await prisma.person.updateMany({ + where: { organizationId: org.id, ownerId: null }, + data: { ownerId: owner.id }, + }); + await prisma.company.updateMany({ + where: { organizationId: org.id, ownerId: null }, + data: { ownerId: owner.id }, + }); + } + if (salesUser && owner) { + const salesDealCount = await prisma.deal.count({ + where: { organizationId: org.id, ownerId: salesUser.id }, + }); + if (salesDealCount === 0) { + const dealsForSales = await prisma.deal.findMany({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + take: 2, + }); + for (const deal of dealsForSales) { + await prisma.deal.update({ + where: { id: deal.id }, + data: { ownerId: salesUser.id }, + }); + if (deal.personId) { + await prisma.person.update({ + where: { id: deal.personId }, + data: { ownerId: salesUser.id }, + }); + } + if (deal.companyId) { + await prisma.company.update({ + where: { id: deal.companyId }, + data: { ownerId: salesUser.id }, + }); + } + } + const act = await prisma.activity.findFirst({ + where: { organizationId: org.id, assigneeId: owner.id }, + orderBy: { createdAt: 'asc' }, + }); + if (act) { + await prisma.activity.update({ + where: { id: act.id }, + data: { assigneeId: salesUser.id }, + }); + } + const quote = await prisma.quote.findFirst({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + if (quote) { + await prisma.quote.update({ + where: { id: quote.id }, + data: { ownerId: salesUser.id }, + }); + } + console.log('Assigned sample records to sales user for ACL demo'); + } + } + + const goalCount = await prisma.salesGoal.count({ + where: { organizationId: org.id }, + }); + if (goalCount === 0 && owner) { + const now = new Date(); + const periodStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + ); + const periodEnd = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59, 999), + ); + await prisma.salesGoal.create({ + data: { + organizationId: org.id, + userId: null, + title: 'هدف فروش سازمانی ماه جاری', + periodStart, + periodEnd, + targetAmount: 200_000_000, + currency: 'IRR', + notes: 'هدف نمونه برای دمو', + }, + }); + const salesUser = await prisma.user.findFirst({ + where: { organizationId: org.id, email: salesEmail }, + }); + if (salesUser) { + await prisma.salesGoal.create({ + data: { + organizationId: org.id, + userId: salesUser.id, + title: 'هدف شخصی کارشناس فروش', + periodStart, + periodEnd, + targetAmount: 80_000_000, + currency: 'IRR', + }, + }); + } + console.log('Seeded sample sales goals'); + } + + const ticketCount = await prisma.ticket.count({ + where: { organizationId: org.id }, + }); + if (ticketCount === 0 && owner) { + const person = await prisma.person.findFirst({ + where: { organizationId: org.id }, + orderBy: { createdAt: 'asc' }, + }); + const supportOrSales = + (await prisma.user.findFirst({ + where: { organizationId: org.id, email: 'sales@demo.local' }, + })) ?? owner; + await prisma.ticket.create({ + data: { + organizationId: org.id, + number: 'TK-0001', + subject: 'درخواست راهنمایی نصب', + description: 'مشتری برای راه‌اندازی اولیه کمک می‌خواهد.', + status: 'open', + priority: 'normal', + personId: person?.id ?? null, + companyId: person?.companyId ?? null, + assigneeId: supportOrSales.id, + reporterId: owner.id, + slaDueAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + messages: { + create: { + authorId: owner.id, + body: 'مشتری برای راه‌اندازی اولیه کمک می‌خواهد.', + isInternal: false, + }, + }, + }, + }); + console.log('Seeded sample ticket'); + } else { + await prisma.ticket.updateMany({ + where: { + organizationId: org.id, + slaDueAt: null, + status: { in: ['open', 'in_progress', 'waiting'] }, + }, + data: { + slaDueAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + } + + const faqCount = await prisma.faqArticle.count({ + where: { organizationId: org.id }, + }); + if (faqCount === 0) { + await prisma.faqArticle.createMany({ + data: [ + { + organizationId: org.id, + question: 'چطور وارد پنل شوم؟', + answer: + 'از صفحه ورود با ایمیل سازمانی وارد شوید. در صورت فراموشی رمز، از مدیر سازمان بخواهید رمز را بازنشانی کند.', + category: 'ورود', + isPublished: true, + position: 1, + }, + { + organizationId: org.id, + question: 'نصب اولیه چقدر طول می‌کشد؟', + answer: + 'راه‌اندازی اولیه معمولاً کمتر از یک ساعت است. فایل راهنما و ویدیوی کوتاه در بخش دانش موجود است.', + category: 'نصب', + isPublished: true, + position: 2, + }, + { + organizationId: org.id, + question: 'چطور تیکت پشتیبانی ثبت کنم؟', + answer: + 'از منوی تیکت‌ها، تیکت جدید بسازید و موضوع و شرح را وارد کنید. قبل از ثبت، پیشنهادهای FAQ را هم ببینید.', + category: 'پشتیبانی', + isPublished: true, + position: 3, + }, + ], + }); + console.log('Seeded sample FAQ articles'); + } +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts new file mode 100644 index 0000000..1f997d7 --- /dev/null +++ b/apps/api/src/activities/activities.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts new file mode 100644 index 0000000..f705229 --- /dev/null +++ b/apps/api/src/activities/activities.module.ts @@ -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 {} diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts new file mode 100644 index 0000000..be7f66b --- /dev/null +++ b/apps/api/src/activities/activities.service.ts @@ -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; + +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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts new file mode 100644 index 0000000..a9842ac --- /dev/null +++ b/apps/api/src/activities/dto.ts @@ -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; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..e15b431 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..f8b4124 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..9f09c12 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -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('JWT_SECRET') ?? 'dev-secret', + signOptions: { + expiresIn: (config.get('JWT_EXPIRES_IN') ?? '7d') as `${number}d`, + }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, RolesGuard], + exports: [AuthService, RolesGuard, JwtModule], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..c267eb7 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/auth/current-user.decorator.ts b/apps/api/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..b3f5c83 --- /dev/null +++ b/apps/api/src/auth/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts new file mode 100644 index 0000000..9420e97 --- /dev/null +++ b/apps/api/src/auth/dto.ts @@ -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; +} diff --git a/apps/api/src/auth/jwt-auth.guard.ts b/apps/api/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/apps/api/src/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..50ad896 --- /dev/null +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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('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, + }; + } +} diff --git a/apps/api/src/auth/roles.decorator.ts b/apps/api/src/auth/roles.decorator.ts new file mode 100644 index 0000000..67f6e57 --- /dev/null +++ b/apps/api/src/auth/roles.decorator.ts @@ -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); diff --git a/apps/api/src/auth/roles.guard.ts b/apps/api/src/auth/roles.guard.ts new file mode 100644 index 0000000..1fa4ac1 --- /dev/null +++ b/apps/api/src/auth/roles.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/common/currency.ts b/apps/api/src/common/currency.ts new file mode 100644 index 0000000..4f3c347 --- /dev/null +++ b/apps/api/src/common/currency.ts @@ -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; +}; + +export function isKnownCurrency(code: string): boolean { + return CURRENCY_CATALOG.some((c) => c.code === code); +} + +export async function loadOrgRates( + prisma: PrismaService, + organizationId: string, +): Promise { + 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(); + 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); +} diff --git a/apps/api/src/common/custom-fields.ts b/apps/api/src/common/custom-fields.ts new file mode 100644 index 0000000..bb72c95 --- /dev/null +++ b/apps/api/src/common/custom-fields.ts @@ -0,0 +1,14 @@ +import { Prisma } from '@prisma/client'; + +export function asCustomFields(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +export function toJsonCustomFields( + value?: Record | null, +): Prisma.InputJsonValue { + return (value ?? {}) as Prisma.InputJsonValue; +} diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts new file mode 100644 index 0000000..86c5257 --- /dev/null +++ b/apps/api/src/common/record-scope.ts @@ -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, +): { ownerId: string } | Record { + if (elevated(actor.role)) return {}; + return { ownerId: actor.userId }; +} + +/** فعالیت‌ها بر اساس مسئول */ +export function assigneeWhere( + actor: Pick, +): { assigneeId: string } | Record { + if (elevated(actor.role)) return {}; + return { assigneeId: actor.userId }; +} + +/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت */ +export function noteAccessWhere( + actor: Pick, +): 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, + requested?: string | null, +): string { + if (elevated(actor.role) && requested?.trim()) { + return requested.trim(); + } + return actor.userId; +} + +/** تیکت: مسئول یا ثبت‌کننده */ +export function ticketAccessWhere( + actor: Pick, +): 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; diff --git a/apps/api/src/common/sms.service.ts b/apps/api/src/common/sms.service.ts new file mode 100644 index 0000000..96ae706 --- /dev/null +++ b/apps/api/src/common/sms.service.ts @@ -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('SMS_USERNAME'); + const password = this.getPassword(); + const sender = this.config.get('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 { + const template = + this.config.get('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 { + const username = this.config.get('SMS_USERNAME'); + const password = this.getPassword(); + const sender = this.config.get('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('SMS_PROVIDER') ?? 'trez').toLowerCase(); + return raw === '0098' ? '0098' : 'trez'; + } + + private resolve0098Transport(): 'soap' | 'link' { + const raw = (this.config.get('SMS_TRANSPORT') ?? 'soap').toLowerCase(); + return raw === 'link' ? 'link' : 'soap'; + } + + private async sendViaTrez( + username: string, + password: string, + sender: string, + to: string, + text: string, + ): Promise { + const baseUrl = ( + this.config.get('SMS_BASE_URL') ?? SMS_TREZ_DEFAULT_BASE_URL + ).replace(/\/$/, ''); + const path = + this.config.get('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 { + const soapBody = ` + + + + ${this.escapeXml(username)} + ${this.escapeXml(password)} + ${this.escapeXml(text)} + ${this.escapeXml(to)} + ${this.escapeXml(sender)} + + +`; + + 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>/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 { + const domain = this.config.get('SMS_DOMAIN') ?? '0098'; + const params = new URLSearchParams({ + FROM: sender, + TO: to, + TEXT: text, + USERNAME: username, + PASSWORD: password, + DOMAIN: domain, + }); + try { + const res = await fetch(`${SMS_0098_LINK_URL}?${params}`, { + signal: AbortSignal.timeout(this.readTimeoutMs()), + }); + const body = (await res.text()).trim(); + if (!res.ok) return { ok: false, transport: 'link', reason: `HTTP ${res.status}` }; + const lower = body.toLowerCase(); + if (lower.includes('error') || lower.includes('fail')) { + return { ok: false, transport: 'link', reason: body.slice(0, 120) }; + } + return { ok: true, transport: 'link' }; + } catch { + return { ok: false, transport: 'link', reason: 'link request failed' }; + } + } + + private escapeXml(value: string): string { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + private readTimeoutMs(): number { + const raw = this.config.get('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('SMS_PASSWORD') ?? + this.config.get('SMS_API_KEY') + ); + } +} diff --git a/apps/api/src/contacts/companies.controller.ts b/apps/api/src/contacts/companies.controller.ts new file mode 100644 index 0000000..58a3225 --- /dev/null +++ b/apps/api/src/contacts/companies.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts new file mode 100644 index 0000000..bacceb6 --- /dev/null +++ b/apps/api/src/contacts/companies.service.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + await this.ensureExists(actor.organizationId, id, actor); + await this.prisma.company.delete({ where: { id } }); + } + + async ensureInOrg( + organizationId: string, + companyId: string, + actor?: Pick, + ): Promise { + await this.ensureExists(organizationId, companyId, actor); + } + + private async ensureExists( + organizationId: string, + id: string, + actor?: Pick, + ) { + 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(), + }; + } +} diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts new file mode 100644 index 0000000..d76a924 --- /dev/null +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -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 = { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): Promise[]> { + 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(); + 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[] = []; + sheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + const item: Record = {}; + 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; + } +} diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts new file mode 100644 index 0000000..4dc9345 --- /dev/null +++ b/apps/api/src/contacts/contacts.module.ts @@ -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 {} diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts new file mode 100644 index 0000000..7e865b5 --- /dev/null +++ b/apps/api/src/contacts/dto.ts @@ -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; + + @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; + + @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; + + @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; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + ownerId?: string | null; +} diff --git a/apps/api/src/contacts/normalize.ts b/apps/api/src/contacts/normalize.ts new file mode 100644 index 0000000..f84c65f --- /dev/null +++ b/apps/api/src/contacts/normalize.ts @@ -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; +} + \ No newline at end of file diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts new file mode 100644 index 0000000..84fc909 --- /dev/null +++ b/apps/api/src/contacts/people.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts new file mode 100644 index 0000000..d04b147 --- /dev/null +++ b/apps/api/src/contacts/people.service.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/custom-fields/custom-fields.controller.ts b/apps/api/src/custom-fields/custom-fields.controller.ts new file mode 100644 index 0000000..608b552 --- /dev/null +++ b/apps/api/src/custom-fields/custom-fields.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/custom-fields/custom-fields.module.ts b/apps/api/src/custom-fields/custom-fields.module.ts new file mode 100644 index 0000000..0835899 --- /dev/null +++ b/apps/api/src/custom-fields/custom-fields.module.ts @@ -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 {} diff --git a/apps/api/src/custom-fields/custom-fields.service.ts b/apps/api/src/custom-fields/custom-fields.service.ts new file mode 100644 index 0000000..bda5feb --- /dev/null +++ b/apps/api/src/custom-fields/custom-fields.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/custom-fields/dto.ts b/apps/api/src/custom-fields/dto.ts new file mode 100644 index 0000000..a0c0e94 --- /dev/null +++ b/apps/api/src/custom-fields/dto.ts @@ -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; +} diff --git a/apps/api/src/faq/dto.ts b/apps/api/src/faq/dto.ts new file mode 100644 index 0000000..02185c3 --- /dev/null +++ b/apps/api/src/faq/dto.ts @@ -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; +} diff --git a/apps/api/src/faq/faq.controller.ts b/apps/api/src/faq/faq.controller.ts new file mode 100644 index 0000000..3c08633 --- /dev/null +++ b/apps/api/src/faq/faq.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/faq/faq.module.ts b/apps/api/src/faq/faq.module.ts new file mode 100644 index 0000000..85f05a1 --- /dev/null +++ b/apps/api/src/faq/faq.module.ts @@ -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 {} diff --git a/apps/api/src/faq/faq.service.ts b/apps/api/src/faq/faq.service.ts new file mode 100644 index 0000000..3d7a407 --- /dev/null +++ b/apps/api/src/faq/faq.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts new file mode 100644 index 0000000..8ca46d5 --- /dev/null +++ b/apps/api/src/health/health.controller.ts @@ -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(), + }; + } +} diff --git a/apps/api/src/health/health.module.ts b/apps/api/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/apps/api/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/api/src/invoices/dto.ts b/apps/api/src/invoices/dto.ts new file mode 100644 index 0000000..4aae033 --- /dev/null +++ b/apps/api/src/invoices/dto.ts @@ -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; +} diff --git a/apps/api/src/invoices/invoices.controller.ts b/apps/api/src/invoices/invoices.controller.ts new file mode 100644 index 0000000..8b2c19b --- /dev/null +++ b/apps/api/src/invoices/invoices.controller.ts @@ -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, + ); + } +} diff --git a/apps/api/src/invoices/invoices.module.ts b/apps/api/src/invoices/invoices.module.ts new file mode 100644 index 0000000..058d12f --- /dev/null +++ b/apps/api/src/invoices/invoices.module.ts @@ -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 {} diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts new file mode 100644 index 0000000..b9e22b6 --- /dev/null +++ b/apps/api/src/invoices/invoices.service.ts @@ -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; + +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> { + 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 { + 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 { + 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 { + 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 { + await this.ensureExists(organizationId, id, actor); + await this.prisma.invoice.delete({ where: { id } }); + } + + async addPayment( + organizationId: string, + invoiceId: string, + actor: InvoiceActor, + dto: CreatePaymentRequest, + ): Promise { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/lead-forms/dto.ts b/apps/api/src/lead-forms/dto.ts new file mode 100644 index 0000000..2e72555 --- /dev/null +++ b/apps/api/src/lead-forms/dto.ts @@ -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; +} diff --git a/apps/api/src/lead-forms/lead-forms.controller.ts b/apps/api/src/lead-forms/lead-forms.controller.ts new file mode 100644 index 0000000..44df335 --- /dev/null +++ b/apps/api/src/lead-forms/lead-forms.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/lead-forms/lead-forms.module.ts b/apps/api/src/lead-forms/lead-forms.module.ts new file mode 100644 index 0000000..4f8f025 --- /dev/null +++ b/apps/api/src/lead-forms/lead-forms.module.ts @@ -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 {} diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts new file mode 100644 index 0000000..825ecda --- /dev/null +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -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 { + 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 { + const row = await this.findInOrg(organizationId, id); + return this.toDto(row); + } + + async create( + organizationId: string, + dto: CreateLeadFormRequest, + ): Promise { + 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 { + 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 { + await this.findInOrg(organizationId, id); + await this.prisma.leadForm.delete({ where: { id } }); + } + + async getPublic(token: string): Promise { + 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, + ): Promise { + 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 { + 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 { + 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 { + 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, +): 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); +} diff --git a/apps/api/src/lead-forms/public-lead-forms.controller.ts b/apps/api/src/lead-forms/public-lead-forms.controller.ts new file mode 100644 index 0000000..ba22cc8 --- /dev/null +++ b/apps/api/src/lead-forms/public-lead-forms.controller.ts @@ -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 ?? {}); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..9759418 --- /dev/null +++ b/apps/api/src/main.ts @@ -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('CORS_ORIGIN') ?? 'http://localhost:5173', + credentials: true, + }); + + const port = Number(config.get('PORT') ?? 3000); + await app.listen(port); + // eslint-disable-next-line no-console + console.log(`Mento API listening on http://localhost:${port}/api`); +} + +bootstrap(); diff --git a/apps/api/src/notes/dto.ts b/apps/api/src/notes/dto.ts new file mode 100644 index 0000000..65691d6 --- /dev/null +++ b/apps/api/src/notes/dto.ts @@ -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; +} diff --git a/apps/api/src/notes/notes.controller.ts b/apps/api/src/notes/notes.controller.ts new file mode 100644 index 0000000..1312438 --- /dev/null +++ b/apps/api/src/notes/notes.controller.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/notes/notes.module.ts b/apps/api/src/notes/notes.module.ts new file mode 100644 index 0000000..72195ee --- /dev/null +++ b/apps/api/src/notes/notes.module.ts @@ -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 {} diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts new file mode 100644 index 0000000..e387d29 --- /dev/null +++ b/apps/api/src/notes/notes.service.ts @@ -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; + +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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + 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(); + 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); +} diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..181b657 --- /dev/null +++ b/apps/api/src/notifications/notifications.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts new file mode 100644 index 0000000..e311df4 --- /dev/null +++ b/apps/api/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts new file mode 100644 index 0000000..a496cf7 --- /dev/null +++ b/apps/api/src/notifications/notifications.service.ts @@ -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 { + 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 { + await this.syncDueItems(organizationId, userId); + return this.prisma.notification.count({ + where: { organizationId, userId, readAt: null }, + }); + } + + async markRead( + organizationId: string, + userId: string, + id: string, + ): Promise { + 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 { + const result = await this.prisma.notification.updateMany({ + where: { organizationId, userId, readAt: null }, + data: { readAt: new Date() }, + }); + return result.count; + } + + async create(input: CreateNotificationInput): Promise { + 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 { + 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 { + 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(); + 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(), + }; + } +} diff --git a/apps/api/src/organizations/dto.ts b/apps/api/src/organizations/dto.ts new file mode 100644 index 0000000..73abe49 --- /dev/null +++ b/apps/api/src/organizations/dto.ts @@ -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[]; +} diff --git a/apps/api/src/organizations/organizations.controller.ts b/apps/api/src/organizations/organizations.controller.ts new file mode 100644 index 0000000..be4329c --- /dev/null +++ b/apps/api/src/organizations/organizations.controller.ts @@ -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, + ); + } +} diff --git a/apps/api/src/organizations/organizations.module.ts b/apps/api/src/organizations/organizations.module.ts new file mode 100644 index 0000000..b18a788 --- /dev/null +++ b/apps/api/src/organizations/organizations.module.ts @@ -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 {} diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts new file mode 100644 index 0000000..029b077 --- /dev/null +++ b/apps/api/src/organizations/organizations.service.ts @@ -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 { + 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 { + 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(), + })), + }; + } +} diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts new file mode 100644 index 0000000..168786c --- /dev/null +++ b/apps/api/src/pipelines/deals.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts new file mode 100644 index 0000000..18cd909 --- /dev/null +++ b/apps/api/src/pipelines/deals.service.ts @@ -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; + +@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 { + const row = await this.findDeal(organizationId, id, actor); + return this.pipelines.toDealDto(row); + } + + async create( + organizationId: string, + actor: DealActor, + dto: CreateDealRequest, + ): Promise { + 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 { + 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 { + 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 { + 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(); + 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, + ); +} diff --git a/apps/api/src/pipelines/dto.ts b/apps/api/src/pipelines/dto.ts new file mode 100644 index 0000000..a8d54b1 --- /dev/null +++ b/apps/api/src/pipelines/dto.ts @@ -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; + + @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; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DealLineItemInputDto) + lineItems?: DealLineItemInputDto[]; +} + +export class MoveDealDto { + @IsString() + stageId!: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + position?: number; +} diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts new file mode 100644 index 0000000..8791467 --- /dev/null +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -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); + } +} diff --git a/apps/api/src/pipelines/pipelines.module.ts b/apps/api/src/pipelines/pipelines.module.ts new file mode 100644 index 0000000..80c526b --- /dev/null +++ b/apps/api/src/pipelines/pipelines.module.ts @@ -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 {} diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts new file mode 100644 index 0000000..95631ef --- /dev/null +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -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 { + 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 { + 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 { + 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, + ): Promise { + 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(); + 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, + }), + ), + }; + } +} diff --git a/apps/api/src/portal/dto.ts b/apps/api/src/portal/dto.ts new file mode 100644 index 0000000..a5e9fe4 --- /dev/null +++ b/apps/api/src/portal/dto.ts @@ -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; +} diff --git a/apps/api/src/portal/portal-auth.ts b/apps/api/src/portal/portal-auth.ts new file mode 100644 index 0000000..f2d928d --- /dev/null +++ b/apps/api/src/portal/portal-auth.ts @@ -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('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; + }, +); diff --git a/apps/api/src/portal/portal.controller.ts b/apps/api/src/portal/portal.controller.ts new file mode 100644 index 0000000..a76c554 --- /dev/null +++ b/apps/api/src/portal/portal.controller.ts @@ -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); + } +} diff --git a/apps/api/src/portal/portal.module.ts b/apps/api/src/portal/portal.module.ts new file mode 100644 index 0000000..d3de2e2 --- /dev/null +++ b/apps/api/src/portal/portal.module.ts @@ -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 {} diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts new file mode 100644 index 0000000..f77d771 --- /dev/null +++ b/apps/api/src/portal/portal.service.ts @@ -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 { + const org = await this.findOrg(slug); + return { name: org.name, slug: org.slug }; + } + + async listFaq(slug: string): Promise { + 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 { + 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('PORTAL_SHOW_OTP') === 'true' || + this.config.get('AUTH_RETURN_OTP') === 'true' || + (this.config.get('NODE_ENV') !== 'production' && + this.config.get('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 { + 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('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 { + 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 { + const row = await this.findTicket(actor, id); + return this.toPortalTicket(row, actor.personId); + } + + async createTicket( + actor: PortalActor, + dto: PortalCreateTicketRequest, + ): Promise { + 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 { + 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 { + 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; +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..bb6565f --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -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(); + } +} diff --git a/apps/api/src/products/dto.ts b/apps/api/src/products/dto.ts new file mode 100644 index 0000000..b6a509e --- /dev/null +++ b/apps/api/src/products/dto.ts @@ -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; +} diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts new file mode 100644 index 0000000..8605662 --- /dev/null +++ b/apps/api/src/products/products.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/products/products.module.ts b/apps/api/src/products/products.module.ts new file mode 100644 index 0000000..cdc091f --- /dev/null +++ b/apps/api/src/products/products.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ProductsController } from './products.controller'; +import { ProductsService } from './products.service'; + +@Module({ + imports: [AuthModule], + controllers: [ProductsController], + providers: [ProductsService], + exports: [ProductsService], +}) +export class ProductsModule {} diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts new file mode 100644 index 0000000..bac6ff7 --- /dev/null +++ b/apps/api/src/products/products.service.ts @@ -0,0 +1,141 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import type { + CreateProductRequest, + ProductDto, + UpdateProductRequest, +} from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +type ProductRow = { + id: string; + name: string; + sku: string | null; + description: string | null; + unitPrice: Prisma.Decimal | null; + currency: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +}; + +@Injectable() +export class ProductsService { + constructor(private readonly prisma: PrismaService) {} + + async list( + organizationId: string, + opts?: { activeOnly?: boolean; q?: string }, + ): Promise { + const rows = await this.prisma.product.findMany({ + where: { + organizationId, + ...(opts?.activeOnly ? { isActive: true } : {}), + ...(opts?.q?.trim() + ? { + OR: [ + { name: { contains: opts.q.trim(), mode: 'insensitive' } }, + { sku: { contains: opts.q.trim(), mode: 'insensitive' } }, + ], + } + : {}), + }, + orderBy: [{ isActive: 'desc' }, { name: 'asc' }], + }); + return rows.map((row) => this.toDto(row)); + } + + async create( + organizationId: string, + dto: CreateProductRequest, + ): Promise { + const name = dto.name.trim(); + if (!name) throw new BadRequestException('نام محصول الزامی است'); + + const row = await this.prisma.product.create({ + data: { + organizationId, + name, + sku: emptyToNull(dto.sku), + description: emptyToNull(dto.description), + unitPrice: + dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice)) + ? null + : new Prisma.Decimal(dto.unitPrice), + currency: (dto.currency ?? 'IRR').trim() || 'IRR', + isActive: dto.isActive ?? true, + }, + }); + return this.toDto(row); + } + + async update( + organizationId: string, + id: string, + dto: UpdateProductRequest, + ): Promise { + await this.ensureExists(organizationId, id); + const data: Prisma.ProductUpdateInput = {}; + if (dto.name !== undefined) { + const name = dto.name.trim(); + if (!name) throw new BadRequestException('نام محصول الزامی است'); + data.name = name; + } + if ('sku' in dto) data.sku = emptyToNull(dto.sku); + if ('description' in dto) data.description = emptyToNull(dto.description); + if ('unitPrice' in dto) { + data.unitPrice = + dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice)) + ? null + : new Prisma.Decimal(dto.unitPrice); + } + if (dto.currency !== undefined) { + data.currency = dto.currency.trim() || 'IRR'; + } + if (dto.isActive !== undefined) data.isActive = dto.isActive; + + const row = await this.prisma.product.update({ where: { id }, data }); + return this.toDto(row); + } + + async remove(organizationId: string, id: string): Promise { + await this.ensureExists(organizationId, id); + await this.prisma.product.delete({ where: { id } }); + } + + async ensureInOrg(organizationId: string, productId: string): Promise { + await this.ensureExists(organizationId, productId); + } + + private async ensureExists(organizationId: string, id: string) { + const found = await this.prisma.product.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + if (!found) throw new NotFoundException('محصول پیدا نشد'); + } + + private toDto(row: ProductRow): ProductDto { + return { + id: row.id, + name: row.name, + sku: row.sku, + description: row.description, + unitPrice: row.unitPrice == null ? null : Number(row.unitPrice), + currency: row.currency, + isActive: row.isActive, + 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; +} diff --git a/apps/api/src/quotes/dto.ts b/apps/api/src/quotes/dto.ts new file mode 100644 index 0000000..f3236b3 --- /dev/null +++ b/apps/api/src/quotes/dto.ts @@ -0,0 +1,169 @@ +import { + ArrayMinSize, + IsArray, + IsIn, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const QUOTE_STATUSES = [ + 'draft', + 'sent', + 'accepted', + 'rejected', + 'expired', +] as const; + +export class QuoteLineItemInputDto { + @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 CreateQuoteDto { + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @IsOptional() + @IsIn(QUOTE_STATUSES) + status?: (typeof QUOTE_STATUSES)[number]; + + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + notes?: string; + + @IsOptional() + @IsString() + validUntil?: string | null; + + @IsOptional() + @IsString() + personId?: string | null; + + @IsOptional() + @IsString() + companyId?: string | null; + + @IsOptional() + @IsString() + dealId?: string | null; + + @IsOptional() + @IsString() + fromDealId?: string | null; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => QuoteLineItemInputDto) + @ArrayMinSize(0) + lineItems?: QuoteLineItemInputDto[]; +} + +export class UpdateQuoteDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(200) + title?: string; + + @IsOptional() + @IsIn(QUOTE_STATUSES) + status?: (typeof QUOTE_STATUSES)[number]; + + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + notes?: string | null; + + @IsOptional() + @IsString() + validUntil?: string | null; + + @IsOptional() + @IsString() + personId?: string | null; + + @IsOptional() + @IsString() + companyId?: string | null; + + @IsOptional() + @IsString() + dealId?: string | null; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => QuoteLineItemInputDto) + lineItems?: QuoteLineItemInputDto[]; +} + +export class ListQuotesQueryDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsIn(QUOTE_STATUSES) + status?: (typeof QUOTE_STATUSES)[number]; + + @IsOptional() + @IsString() + dealId?: string; + + @IsOptional() + @IsString() + personId?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number; +} diff --git a/apps/api/src/quotes/quotes.controller.ts b/apps/api/src/quotes/quotes.controller.ts new file mode 100644 index 0000000..9d21601 --- /dev/null +++ b/apps/api/src/quotes/quotes.controller.ts @@ -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 { + CreateQuoteDto, + ListQuotesQueryDto, + UpdateQuoteDto, +} from './dto'; +import { QuotesService } from './quotes.service'; + +type AuthUser = { organizationId: string; userId: string; role: string }; + +@Controller('quotes') +@UseGuards(JwtAuthGuard) +export class QuotesController { + constructor(private readonly quotesService: QuotesService) {} + + @Get() + list( + @CurrentUser() user: AuthUser, + @Query() query: ListQuotesQueryDto, + ) { + return this.quotesService.list(user.organizationId, user, { + q: query.q, + status: query.status, + dealId: query.dealId, + personId: query.personId, + page: query.page ?? 1, + limit: query.limit ?? 50, + }); + } + + @Get(':id') + get(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.quotesService.get(user.organizationId, id, user); + } + + @Post() + create(@CurrentUser() user: AuthUser, @Body() dto: CreateQuoteDto) { + return this.quotesService.create(user.organizationId, user.userId, dto); + } + + @Patch(':id') + update( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateQuoteDto, + ) { + return this.quotesService.update(user.organizationId, id, user, dto); + } + + @Delete(':id') + async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + await this.quotesService.remove(user.organizationId, id, user); + return { ok: true }; + } +} diff --git a/apps/api/src/quotes/quotes.module.ts b/apps/api/src/quotes/quotes.module.ts new file mode 100644 index 0000000..cde01a9 --- /dev/null +++ b/apps/api/src/quotes/quotes.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { QuotesController } from './quotes.controller'; +import { QuotesService } from './quotes.service'; + +@Module({ + controllers: [QuotesController], + providers: [QuotesService], + exports: [QuotesService], +}) +export class QuotesModule {} diff --git a/apps/api/src/quotes/quotes.service.ts b/apps/api/src/quotes/quotes.service.ts new file mode 100644 index 0000000..602eeed --- /dev/null +++ b/apps/api/src/quotes/quotes.service.ts @@ -0,0 +1,367 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + CreateQuoteRequest, + DealLineItemInput, + PaginatedResponse, + QuoteDto, + UpdateQuoteRequest, +} from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { type Actor, ownerWhere } from '../common/record-scope'; + +type QuoteActor = Pick; + +const include = { + person: { select: { id: true, firstName: true, lastName: true } }, + company: { select: { id: true, name: true } }, + deal: { select: { id: true, title: true } }, + owner: { select: { id: true, fullName: true } }, + lineItems: { orderBy: { position: 'asc' as const } }, +} satisfies Prisma.QuoteInclude; + +type QuoteRow = Prisma.QuoteGetPayload<{ include: typeof include }>; + +@Injectable() +export class QuotesService { + constructor(private readonly prisma: PrismaService) {} + + async list( + organizationId: string, + actor: QuoteActor, + opts: { + q?: string; + status?: string; + dealId?: string; + personId?: string; + page?: number; + limit?: number; + }, + ): Promise> { + const page = opts.page ?? 1; + const limit = opts.limit ?? 50; + const where: Prisma.QuoteWhereInput = { + organizationId, + ...ownerWhere(actor), + ...(opts.status ? { status: opts.status as never } : {}), + ...(opts.dealId ? { dealId: opts.dealId } : {}), + ...(opts.personId ? { personId: opts.personId } : {}), + ...(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.quote.findMany({ + where, + include, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.quote.count({ where }), + ]); + + return { + items: rows.map((row) => this.toDto(row)), + total, + page, + limit, + }; + } + + async get( + organizationId: string, + id: string, + actor: QuoteActor, + ): Promise { + const row = await this.prisma.quote.findFirst({ + where: { id, organizationId, ...ownerWhere(actor) }, + include, + }); + if (!row) throw new NotFoundException('پیش‌فاکتور پیدا نشد'); + return this.toDto(row); + } + + async create( + organizationId: string, + userId: string, + dto: CreateQuoteRequest, + ): Promise { + let title = dto.title.trim(); + let personId = emptyToNull(dto.personId ?? null); + let companyId = emptyToNull(dto.companyId ?? null); + let dealId = emptyToNull(dto.dealId ?? null); + let lineItems = dto.lineItems ?? []; + let currency = (dto.currency ?? 'IRR').trim() || 'IRR'; + let notes = emptyToNull(dto.notes ?? null); + + 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, + })); + } + } + + await this.assertLinks(organizationId, personId, companyId, dealId); + if (lineItems.length) { + await this.assertProducts(organizationId, lineItems); + } + + const number = await this.nextNumber(organizationId); + const row = await this.prisma.quote.create({ + data: { + organizationId, + number, + title, + status: (dto.status as never) || 'draft', + currency, + notes, + validUntil: dto.validUntil ? new Date(dto.validUntil) : null, + personId, + companyId, + dealId, + ownerId: userId, + ...(lineItems.length + ? { + lineItems: { + create: 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, + }); + return this.toDto(row); + } + + async update( + organizationId: string, + id: string, + actor: QuoteActor, + dto: UpdateQuoteRequest, + ): Promise { + await this.ensureExists(organizationId, id, actor); + if ( + 'personId' in dto || + 'companyId' in dto || + 'dealId' in dto + ) { + await this.assertLinks( + organizationId, + emptyToNull(dto.personId ?? null), + emptyToNull(dto.companyId ?? null), + emptyToNull(dto.dealId ?? null), + ); + } + if (dto.lineItems?.length) { + await this.assertProducts(organizationId, dto.lineItems); + } + + const data: Prisma.QuoteUncheckedUpdateInput = {}; + 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 ('validUntil' in dto) { + data.validUntil = dto.validUntil ? new Date(dto.validUntil) : 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 (dto.lineItems) { + const currency = + (typeof data.currency === 'string' && data.currency) || + ( + await this.prisma.quote.findFirst({ + where: { id }, + select: { currency: true }, + }) + )?.currency || + 'IRR'; + await this.prisma.$transaction([ + this.prisma.quoteLineItem.deleteMany({ where: { quoteId: id } }), + this.prisma.quote.update({ + where: { id }, + data: { + ...data, + 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, + })), + }, + }, + }), + ]); + } else if (Object.keys(data).length) { + await this.prisma.quote.update({ where: { id }, data }); + } + + return this.get(organizationId, id, actor); + } + + async remove( + organizationId: string, + id: string, + actor: QuoteActor, + ): Promise { + await this.ensureExists(organizationId, id, actor); + await this.prisma.quote.delete({ where: { id } }); + } + + private async nextNumber(organizationId: string): Promise { + const count = await this.prisma.quote.count({ where: { organizationId } }); + return `PF-${String(count + 1).padStart(4, '0')}`; + } + + private async ensureExists( + organizationId: string, + id: string, + actor: QuoteActor, + ) { + const found = await this.prisma.quote.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, + ) { + 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('معامله معتبر نیست'); + } + } + + 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: QuoteRow): QuoteDto { + 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); + return { + id: row.id, + number: row.number, + title: row.title, + status: row.status, + currency: row.currency, + notes: row.notes, + validUntil: row.validUntil?.toISOString() ?? null, + totalAmount, + person: row.person + ? { + id: row.person.id, + fullName: `${row.person.firstName} ${row.person.lastName}`.trim(), + } + : null, + company: row.company, + deal: row.deal, + owner: row.owner, + lineItems, + 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; +} diff --git a/apps/api/src/reports/dto.ts b/apps/api/src/reports/dto.ts new file mode 100644 index 0000000..92ffcf2 --- /dev/null +++ b/apps/api/src/reports/dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class ReportsQueryDto { + @IsOptional() + @IsString() + from?: string; + + @IsOptional() + @IsString() + to?: string; +} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts new file mode 100644 index 0000000..60b13f8 --- /dev/null +++ b/apps/api/src/reports/reports.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { ReportsQueryDto } from './dto'; +import { ReportsService } from './reports.service'; + +@Controller('reports') +@UseGuards(JwtAuthGuard) +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get('dashboard') + dashboard( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Query() query: ReportsQueryDto, + ) { + return this.reportsService.dashboard( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + { from: query.from, to: query.to }, + ); + } +} diff --git a/apps/api/src/reports/reports.module.ts b/apps/api/src/reports/reports.module.ts new file mode 100644 index 0000000..fd97715 --- /dev/null +++ b/apps/api/src/reports/reports.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; + +@Module({ + imports: [AuthModule], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts new file mode 100644 index 0000000..b801fb0 --- /dev/null +++ b/apps/api/src/reports/reports.service.ts @@ -0,0 +1,271 @@ +import { Injectable } from '@nestjs/common'; +import type { ReportsDashboardDto } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + type Actor, + assigneeWhere, + elevated, + ownerWhere, + ticketAccessWhere, +} from '../common/record-scope'; +import { decimalToBase, loadOrgRates } from '../common/currency'; + +@Injectable() +export class ReportsService { + constructor(private readonly prisma: PrismaService) {} + + async dashboard( + actor: Actor, + opts: { from?: string; to?: string }, + ): Promise { + const { start, end } = resolveRange(opts.from, opts.to); + const orgId = actor.organizationId; + const dealScope = ownerWhere(actor); + const activityScope = assigneeWhere(actor); + const ticketScope = ticketAccessWhere(actor); + + const rates = await loadOrgRates(this.prisma, orgId); + + const [deals, stages, activities, tickets, peopleCreated, users] = + await Promise.all([ + this.prisma.deal.findMany({ + where: { + organizationId: orgId, + ...dealScope, + OR: [ + { createdAt: { gte: start, lte: end } }, + { + status: { in: ['won', 'lost'] }, + updatedAt: { gte: start, lte: end }, + }, + { status: 'open' }, + ], + }, + select: { + id: true, + status: true, + amount: true, + currency: true, + stageId: true, + ownerId: true, + updatedAt: true, + createdAt: true, + }, + }), + this.prisma.pipelineStage.findMany({ + where: { pipeline: { organizationId: orgId } }, + include: { pipeline: { select: { name: true } } }, + orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }], + }), + this.prisma.activity.findMany({ + where: { + organizationId: orgId, + ...activityScope, + createdAt: { gte: start, lte: end }, + }, + select: { + id: true, + type: true, + status: true, + dueAt: true, + assigneeId: true, + }, + }), + this.prisma.ticket.findMany({ + where: { + organizationId: orgId, + ...ticketScope, + createdAt: { gte: start, lte: end }, + }, + select: { + status: true, + slaDueAt: true, + firstRespondedAt: true, + csatScore: true, + }, + }), + this.prisma.person.count({ + where: { + organizationId: orgId, + createdAt: { gte: start, lte: end }, + ...(elevated(actor.role) ? {} : { ownerId: actor.userId }), + }, + }), + this.prisma.user.findMany({ + where: { organizationId: orgId }, + select: { id: true, fullName: true }, + }), + ]); + + const openDeals = deals.filter((d) => d.status === 'open'); + const wonInPeriod = deals.filter( + (d) => + d.status === 'won' && + d.updatedAt >= start && + d.updatedAt <= end, + ); + const lostInPeriod = deals.filter( + (d) => + d.status === 'lost' && + d.updatedAt >= start && + d.updatedAt <= end, + ); + + const sumBase = ( + rows: Array<{ amount: Prisma.Decimal | null; currency: string }>, + ) => + rows.reduce( + (acc, row) => acc + decimalToBase(row.amount, row.currency, rates), + 0, + ); + + const closedCount = wonInPeriod.length + lostInPeriod.length; + const winRate = closedCount ? wonInPeriod.length / closedCount : 0; + + const openByStage = new Map(); + for (const d of openDeals) { + const cur = openByStage.get(d.stageId) ?? { count: 0, amount: 0 }; + cur.count += 1; + cur.amount += decimalToBase(d.amount, d.currency, rates); + openByStage.set(d.stageId, cur); + } + + const funnel = stages.map((s) => { + const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 }; + return { + stageId: s.id, + stageName: s.name, + pipelineName: s.pipeline.name, + count: agg.count, + amount: Math.round(agg.amount), + }; + }); + + const now = new Date(); + const activityOpen = activities.filter((a) => a.status === 'open').length; + const activityDone = activities.filter((a) => a.status === 'done').length; + const activityOverdue = activities.filter( + (a) => + a.status === 'open' && + a.dueAt != null && + a.dueAt.getTime() < now.getTime(), + ).length; + const byTypeMap = new Map(); + for (const a of activities) { + byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1); + } + + const teamMap = new Map< + string, + { wonAmount: number; wonCount: number; activitiesDone: number } + >(); + for (const u of users) { + teamMap.set(u.id, { wonAmount: 0, wonCount: 0, activitiesDone: 0 }); + } + for (const d of wonInPeriod) { + if (!d.ownerId) continue; + const t = teamMap.get(d.ownerId)!; + t.wonCount += 1; + t.wonAmount += decimalToBase(d.amount, d.currency, rates); + } + for (const a of activities) { + if (a.status !== 'done' || !a.assigneeId) continue; + const t = teamMap.get(a.assigneeId); + if (t) t.activitiesDone += 1; + } + + const ticketOpen = tickets.filter((t) => + ['open', 'in_progress', 'waiting'].includes(t.status), + ).length; + const ticketResolved = tickets.filter( + (t) => t.status === 'resolved', + ).length; + const ticketClosed = tickets.filter((t) => t.status === 'closed').length; + const slaBreached = tickets.filter( + (t) => + t.slaDueAt && + !t.firstRespondedAt && + ['open', 'in_progress', 'waiting'].includes(t.status) && + t.slaDueAt.getTime() < now.getTime(), + ).length; + const csatScores = tickets + .map((t) => t.csatScore) + .filter((s): s is number => s != null); + const avgCsat = csatScores.length + ? csatScores.reduce((a, b) => a + b, 0) / csatScores.length + : null; + + return { + from: start.toISOString(), + to: end.toISOString(), + currency: rates.baseCurrency, + sales: { + openCount: openDeals.length, + wonCount: wonInPeriod.length, + lostCount: lostInPeriod.length, + openAmount: Math.round(sumBase(openDeals)), + wonAmount: Math.round(sumBase(wonInPeriod)), + lostAmount: Math.round(sumBase(lostInPeriod)), + winRate: Math.round(winRate * 1000) / 10, + }, + funnel, + activities: { + open: activityOpen, + done: activityDone, + overdue: activityOverdue, + byType: [...byTypeMap.entries()].map(([type, count]) => ({ + type, + count, + })), + }, + team: users + .map((u) => { + const t = teamMap.get(u.id)!; + return { + userId: u.id, + fullName: u.fullName, + wonAmount: Math.round(t.wonAmount), + wonCount: t.wonCount, + activitiesDone: t.activitiesDone, + }; + }) + .filter( + (u) => + elevated(actor.role) || u.wonCount > 0 || u.activitiesDone > 0, + ) + .sort((a, b) => b.wonAmount - a.wonAmount), + tickets: { + open: ticketOpen, + resolved: ticketResolved, + closed: ticketClosed, + slaBreached, + avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10, + csatCount: csatScores.length, + }, + peopleCreated, + }; + } +} + +function resolveRange(from?: string, to?: string): { start: Date; end: Date } { + const end = to ? endOfDay(parseDay(to)) : endOfDay(new Date()); + const start = from + ? startOfDay(parseDay(from)) + : startOfDay(new Date(end.getFullYear(), end.getMonth(), 1)); + return { start, end }; +} + +function parseDay(value: string): Date { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return new Date(); + return d; +} + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); +} + +function endOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999); +} diff --git a/apps/api/src/sales/dto.ts b/apps/api/src/sales/dto.ts new file mode 100644 index 0000000..691138f --- /dev/null +++ b/apps/api/src/sales/dto.ts @@ -0,0 +1,105 @@ +import { + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateSalesGoalDto { + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @IsString() + periodStart!: string; + + @IsString() + periodEnd!: string; + + @Type(() => Number) + @IsNumber() + @Min(0) + targetAmount!: number; + + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; + + @IsOptional() + @IsString() + userId?: string | null; +} + +export class UpdateSalesGoalDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + periodStart?: string; + + @IsOptional() + @IsString() + periodEnd?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + targetAmount?: number; + + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string | null; + + @IsOptional() + @IsString() + userId?: string | null; +} + +export class ForecastQueryDto { + @IsOptional() + @IsString() + from?: string; + + @IsOptional() + @IsString() + to?: string; + + @IsOptional() + @IsString() + userId?: string; +} + +export class ListGoalsQueryDto { + @IsOptional() + @IsString() + from?: string; + + @IsOptional() + @IsString() + to?: string; + + @IsOptional() + @IsString() + userId?: string; +} diff --git a/apps/api/src/sales/sales.controller.ts b/apps/api/src/sales/sales.controller.ts new file mode 100644 index 0000000..5710c07 --- /dev/null +++ b/apps/api/src/sales/sales.controller.ts @@ -0,0 +1,125 @@ +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 { + CreateSalesGoalDto, + ForecastQueryDto, + ListGoalsQueryDto, + UpdateSalesGoalDto, +} from './dto'; +import { SalesService } from './sales.service'; + +@Controller('sales') +@UseGuards(JwtAuthGuard) +export class SalesController { + constructor(private readonly salesService: SalesService) {} + + @Get('forecast') + forecast( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Query() query: ForecastQueryDto, + ) { + return this.salesService.forecast( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + { from: query.from, to: query.to, userId: query.userId }, + ); + } + + @Get('goals') + listGoals( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Query() query: ListGoalsQueryDto, + ) { + return this.salesService.listGoals( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + { from: query.from, to: query.to, userId: query.userId }, + ); + } + + @Get('goals/:id') + getGoal( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Param('id') id: string, + ) { + return this.salesService.getGoal( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + id, + ); + } + + @Post('goals') + createGoal( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Body() dto: CreateSalesGoalDto, + ) { + return this.salesService.createGoal( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + dto, + ); + } + + @Patch('goals/:id') + updateGoal( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Param('id') id: string, + @Body() dto: UpdateSalesGoalDto, + ) { + return this.salesService.updateGoal( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + id, + dto, + ); + } + + @Delete('goals/:id') + async removeGoal( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Param('id') id: string, + ) { + await this.salesService.removeGoal( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + id, + ); + return { ok: true }; + } +} diff --git a/apps/api/src/sales/sales.module.ts b/apps/api/src/sales/sales.module.ts new file mode 100644 index 0000000..bc510d5 --- /dev/null +++ b/apps/api/src/sales/sales.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SalesController } from './sales.controller'; +import { SalesService } from './sales.service'; + +@Module({ + controllers: [SalesController], + providers: [SalesService], + exports: [SalesService], +}) +export class SalesModule {} diff --git a/apps/api/src/sales/sales.service.ts b/apps/api/src/sales/sales.service.ts new file mode 100644 index 0000000..4522bc8 --- /dev/null +++ b/apps/api/src/sales/sales.service.ts @@ -0,0 +1,338 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + CreateSalesGoalRequest, + PaginatedResponse, + SalesForecastDto, + SalesGoalDto, + UpdateSalesGoalRequest, +} from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + type Actor, + elevated, + ownerWhere, +} from '../common/record-scope'; +import { decimalToBase, loadOrgRates } from '../common/currency'; + +const include = { + user: { select: { id: true, fullName: true } }, +} satisfies Prisma.SalesGoalInclude; + +type GoalRow = Prisma.SalesGoalGetPayload<{ include: typeof include }>; + +const OPEN_WEIGHT = 0.4; + +@Injectable() +export class SalesService { + constructor(private readonly prisma: PrismaService) {} + + async listGoals( + actor: Actor, + opts: { from?: string; to?: string; userId?: string } = {}, + ): Promise> { + const { start, end } = resolvePeriod(opts.from, opts.to); + const where: Prisma.SalesGoalWhereInput = { + organizationId: actor.organizationId, + periodStart: { lte: end }, + periodEnd: { gte: start }, + ...this.goalVisibility(actor, opts.userId), + }; + + const rows = await this.prisma.salesGoal.findMany({ + where, + include, + orderBy: [{ periodStart: 'desc' }, { createdAt: 'desc' }], + take: 100, + }); + + return { + items: rows.map((row) => this.toGoalDto(row)), + total: rows.length, + page: 1, + limit: 100, + }; + } + + async getGoal(actor: Actor, id: string): Promise { + const row = await this.prisma.salesGoal.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...this.goalVisibility(actor), + }, + include, + }); + if (!row) throw new NotFoundException('هدف پیدا نشد'); + return this.toGoalDto(row); + } + + async createGoal( + actor: Actor, + dto: CreateSalesGoalRequest, + ): Promise { + const periodStart = parseDate(dto.periodStart, 'شروع دوره'); + const periodEnd = parseDate(dto.periodEnd, 'پایان دوره'); + if (periodEnd < periodStart) { + throw new BadRequestException('پایان دوره باید بعد از شروع باشد'); + } + + const userId = await this.resolveGoalUserId(actor, dto.userId); + + const row = await this.prisma.salesGoal.create({ + data: { + organizationId: actor.organizationId, + userId, + title: dto.title.trim(), + periodStart, + periodEnd, + targetAmount: new Prisma.Decimal(dto.targetAmount), + currency: (dto.currency ?? 'IRR').trim() || 'IRR', + notes: emptyToNull(dto.notes ?? null), + }, + include, + }); + return this.toGoalDto(row); + } + + async updateGoal( + actor: Actor, + id: string, + dto: UpdateSalesGoalRequest, + ): Promise { + const existing = await this.prisma.salesGoal.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...this.goalVisibility(actor), + }, + }); + if (!existing) throw new NotFoundException('هدف پیدا نشد'); + if (!elevated(actor.role) && existing.userId !== actor.userId) { + throw new ForbiddenException('اجازه ویرایش این هدف را ندارید'); + } + + const data: Prisma.SalesGoalUncheckedUpdateInput = {}; + if (dto.title !== undefined) data.title = dto.title.trim(); + if (dto.periodStart !== undefined) { + data.periodStart = parseDate(dto.periodStart, 'شروع دوره'); + } + if (dto.periodEnd !== undefined) { + data.periodEnd = parseDate(dto.periodEnd, 'پایان دوره'); + } + if (dto.targetAmount !== undefined) { + data.targetAmount = new Prisma.Decimal(dto.targetAmount); + } + if (dto.currency !== undefined) { + data.currency = dto.currency.trim() || 'IRR'; + } + if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null); + if ('userId' in dto) { + data.userId = await this.resolveGoalUserId(actor, dto.userId); + } + + const start = + data.periodStart instanceof Date + ? data.periodStart + : existing.periodStart; + const end = + data.periodEnd instanceof Date ? data.periodEnd : existing.periodEnd; + if (end < start) { + throw new BadRequestException('پایان دوره باید بعد از شروع باشد'); + } + + const row = await this.prisma.salesGoal.update({ + where: { id }, + data, + include, + }); + return this.toGoalDto(row); + } + + async removeGoal(actor: Actor, id: string): Promise { + const existing = await this.prisma.salesGoal.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...this.goalVisibility(actor), + }, + }); + if (!existing) throw new NotFoundException('هدف پیدا نشد'); + if (!elevated(actor.role) && existing.userId !== actor.userId) { + throw new ForbiddenException('اجازه حذف این هدف را ندارید'); + } + await this.prisma.salesGoal.delete({ where: { id } }); + } + + async forecast( + actor: Actor, + opts: { from?: string; to?: string; userId?: string } = {}, + ): Promise { + const { start, end } = resolvePeriod(opts.from, opts.to); + const dealOwner = + elevated(actor.role) && opts.userId + ? { ownerId: opts.userId } + : ownerWhere(actor); + + const goalsWhere: Prisma.SalesGoalWhereInput = { + organizationId: actor.organizationId, + periodStart: { lte: end }, + periodEnd: { gte: start }, + ...this.goalVisibility(actor, opts.userId), + }; + + const [goals, wonDeals, openDeals, rateMap] = await Promise.all([ + this.prisma.salesGoal.findMany({ + where: goalsWhere, + include, + orderBy: { periodStart: 'asc' }, + }), + this.prisma.deal.findMany({ + where: { + organizationId: actor.organizationId, + status: 'won', + updatedAt: { gte: start, lte: end }, + ...dealOwner, + }, + select: { amount: true, currency: true }, + }), + this.prisma.deal.findMany({ + where: { + organizationId: actor.organizationId, + status: 'open', + ...dealOwner, + }, + select: { amount: true, currency: true }, + }), + loadOrgRates(this.prisma, actor.organizationId), + ]); + + const targetAmount = goals.reduce( + (sum, g) => + sum + decimalToBase(g.targetAmount, g.currency, rateMap), + 0, + ); + const wonAmount = wonDeals.reduce( + (sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap), + 0, + ); + const openPipelineAmount = openDeals.reduce( + (sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap), + 0, + ); + const forecastAmount = wonAmount + openPipelineAmount * OPEN_WEIGHT; + const progressPercent = + targetAmount > 0 + ? Math.round((wonAmount / targetAmount) * 1000) / 10 + : null; + + return { + periodStart: start.toISOString(), + periodEnd: end.toISOString(), + currency: rateMap.baseCurrency, + targetAmount, + wonAmount, + openPipelineAmount, + forecastAmount, + progressPercent, + openDealsCount: openDeals.length, + wonDealsCount: wonDeals.length, + goals: goals.map((g) => this.toGoalDto(g)), + }; + } + + private goalVisibility( + actor: Actor, + filterUserId?: string, + ): Prisma.SalesGoalWhereInput { + if (elevated(actor.role)) { + if (filterUserId === 'org') return { userId: null }; + if (filterUserId) return { userId: filterUserId }; + return {}; + } + return { + OR: [{ userId: actor.userId }, { userId: null }], + }; + } + + private async resolveGoalUserId( + actor: Actor, + requested?: string | null, + ): Promise { + if (!elevated(actor.role)) { + return actor.userId; + } + if (requested === undefined) return null; + if (requested == null || requested === '' || requested === 'org') { + return null; + } + const user = await this.prisma.user.findFirst({ + where: { id: requested, organizationId: actor.organizationId }, + select: { id: true }, + }); + if (!user) throw new BadRequestException('کاربر هدف معتبر نیست'); + return user.id; + } + + private toGoalDto(row: GoalRow): SalesGoalDto { + return { + id: row.id, + title: row.title, + periodStart: row.periodStart.toISOString(), + periodEnd: row.periodEnd.toISOString(), + targetAmount: Number(row.targetAmount), + currency: row.currency, + notes: row.notes, + user: row.user, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} + +function resolvePeriod(from?: string, to?: string): { start: Date; end: Date } { + if (from || to) { + const start = from + ? parseDate(from, 'از تاریخ') + : startOfMonth(new Date()); + const end = to ? endOfDay(parseDate(to, 'تا تاریخ')) : endOfMonth(start); + return { start, end }; + } + const now = new Date(); + return { start: startOfMonth(now), end: endOfMonth(now) }; +} + +function parseDate(value: string, label: string): Date { + const d = new Date(value); + if (Number.isNaN(d.getTime())) { + throw new BadRequestException(`${label} معتبر نیست`); + } + return d; +} + +function startOfMonth(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0)); +} + +function endOfMonth(d: Date): Date { + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0, 23, 59, 59, 999), + ); +} + +function endOfDay(d: Date): Date { + const x = new Date(d); + x.setUTCHours(23, 59, 59, 999); + return x; +} + +function emptyToNull(value?: string | null): string | null { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} diff --git a/apps/api/src/storage/local-file.storage.ts b/apps/api/src/storage/local-file.storage.ts new file mode 100644 index 0000000..274982e --- /dev/null +++ b/apps/api/src/storage/local-file.storage.ts @@ -0,0 +1,48 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createReadStream, promises as fs } from 'fs'; +import * as path from 'path'; +import type { Readable } from 'stream'; + +@Injectable() +export class LocalFileStorage implements OnModuleInit { + private readonly root: string; + + constructor(private readonly config: ConfigService) { + this.root = + this.config.get('UPLOAD_DIR')?.trim() || + path.join(process.cwd(), 'uploads'); + } + + async onModuleInit() { + await fs.mkdir(this.root, { recursive: true }); + } + + absolutePath(storageKey: string): string { + const resolved = path.resolve(this.root, storageKey); + if (!resolved.startsWith(path.resolve(this.root))) { + throw new Error('Invalid storage key'); + } + return resolved; + } + + async save(storageKey: string, buffer: Buffer): Promise { + const abs = this.absolutePath(storageKey); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, buffer); + } + + async remove(storageKey: string): Promise { + const abs = this.absolutePath(storageKey); + try { + await fs.unlink(abs); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + } + + openReadStream(storageKey: string): Readable { + return createReadStream(this.absolutePath(storageKey)); + } +} diff --git a/apps/api/src/storage/storage.module.ts b/apps/api/src/storage/storage.module.ts new file mode 100644 index 0000000..3888525 --- /dev/null +++ b/apps/api/src/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { LocalFileStorage } from './local-file.storage'; + +@Global() +@Module({ + providers: [LocalFileStorage], + exports: [LocalFileStorage], +}) +export class StorageModule {} diff --git a/apps/api/src/tickets/dto.ts b/apps/api/src/tickets/dto.ts new file mode 100644 index 0000000..b230be5 --- /dev/null +++ b/apps/api/src/tickets/dto.ts @@ -0,0 +1,157 @@ +import { + IsBoolean, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const STATUSES = [ + 'open', + 'in_progress', + 'waiting', + 'resolved', + 'closed', +] as const; + +const PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const; + +export class CreateTicketDto { + @IsString() + @MinLength(2) + @MaxLength(200) + subject!: string; + + @IsOptional() + @IsString() + @MaxLength(8000) + description?: string; + + @IsOptional() + @IsIn(STATUSES) + status?: (typeof STATUSES)[number]; + + @IsOptional() + @IsIn(PRIORITIES) + priority?: (typeof PRIORITIES)[number]; + + @IsOptional() + @IsString() + personId?: string | null; + + @IsOptional() + @IsString() + companyId?: string | null; + + @IsOptional() + @IsString() + dealId?: string | null; + + @IsOptional() + @IsString() + assigneeId?: string | null; +} + +export class UpdateTicketDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(200) + subject?: string; + + @IsOptional() + @IsString() + @MaxLength(8000) + description?: string | null; + + @IsOptional() + @IsIn(STATUSES) + status?: (typeof STATUSES)[number]; + + @IsOptional() + @IsIn(PRIORITIES) + priority?: (typeof PRIORITIES)[number]; + + @IsOptional() + @IsString() + personId?: string | null; + + @IsOptional() + @IsString() + companyId?: string | null; + + @IsOptional() + @IsString() + dealId?: string | null; + + @IsOptional() + @IsString() + assigneeId?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(5) + csatScore?: number | null; + + @IsOptional() + @IsString() + @MaxLength(2000) + csatComment?: string | null; +} + +export class CreateTicketMessageDto { + @IsString() + @MinLength(1) + @MaxLength(8000) + body!: string; + + @IsOptional() + @IsBoolean() + isInternal?: boolean; +} + +export class ListTicketsQueryDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsIn(STATUSES) + status?: (typeof STATUSES)[number]; + + @IsOptional() + @IsIn(PRIORITIES) + priority?: (typeof PRIORITIES)[number]; + + @IsOptional() + @IsString() + personId?: string; + + @IsOptional() + @IsString() + assigneeId?: string; + + @IsOptional() + @IsIn(['overdue']) + sla?: 'overdue'; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number; +} diff --git a/apps/api/src/tickets/tickets.controller.ts b/apps/api/src/tickets/tickets.controller.ts new file mode 100644 index 0000000..f7c90c4 --- /dev/null +++ b/apps/api/src/tickets/tickets.controller.ts @@ -0,0 +1,136 @@ +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 { + CreateTicketDto, + CreateTicketMessageDto, + ListTicketsQueryDto, + UpdateTicketDto, +} from './dto'; +import { TicketsService } from './tickets.service'; + +@Controller('tickets') +@UseGuards(JwtAuthGuard) +export class TicketsController { + constructor(private readonly ticketsService: TicketsService) {} + + @Get() + list( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Query() query: ListTicketsQueryDto, + ) { + return this.ticketsService.list( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + { + q: query.q, + status: query.status, + priority: query.priority, + personId: query.personId, + assigneeId: query.assigneeId, + sla: query.sla, + page: query.page ?? 1, + limit: query.limit ?? 50, + }, + ); + } + + @Get(':id') + get( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Param('id') id: string, + ) { + return this.ticketsService.get( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + id, + ); + } + + @Post() + create( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Body() dto: CreateTicketDto, + ) { + return this.ticketsService.create( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + dto, + ); + } + + @Patch(':id') + update( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Param('id') id: string, + @Body() dto: UpdateTicketDto, + ) { + return this.ticketsService.update( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + id, + dto, + ); + } + + @Delete(':id') + async remove( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Param('id') id: string, + ) { + await this.ticketsService.remove( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + id, + ); + return { ok: true }; + } + + @Post(':id/messages') + addMessage( + @CurrentUser() + user: { userId: string; organizationId: string; role: string }, + @Param('id') id: string, + @Body() dto: CreateTicketMessageDto, + ) { + return this.ticketsService.addMessage( + { + userId: user.userId, + organizationId: user.organizationId, + role: user.role, + }, + id, + dto, + ); + } +} diff --git a/apps/api/src/tickets/tickets.module.ts b/apps/api/src/tickets/tickets.module.ts new file mode 100644 index 0000000..56f1b1b --- /dev/null +++ b/apps/api/src/tickets/tickets.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { TicketsController } from './tickets.controller'; +import { TicketsService } from './tickets.service'; + +@Module({ + imports: [NotificationsModule], + controllers: [TicketsController], + providers: [TicketsService], + exports: [TicketsService], +}) +export class TicketsModule {} diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts new file mode 100644 index 0000000..71edf41 --- /dev/null +++ b/apps/api/src/tickets/tickets.service.ts @@ -0,0 +1,485 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + CreateTicketMessageRequest, + CreateTicketRequest, + PaginatedResponse, + TicketDto, + TicketPriority, + UpdateTicketRequest, +} from '@mento/shared'; +import { TICKET_SLA_HOURS } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + type Actor, + elevated, + ticketAccessWhere, +} from '../common/record-scope'; +import { NotificationsService } from '../notifications/notifications.service'; + +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 } }, + reporter: { select: { id: true, fullName: true } }, + messages: { + orderBy: { createdAt: 'asc' as const }, + include: { author: { select: { id: true, fullName: true } } }, + }, +} satisfies Prisma.TicketInclude; + +type TicketRow = Prisma.TicketGetPayload<{ include: typeof include }>; + +@Injectable() +export class TicketsService { + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + ) {} + + async list( + actor: Actor, + opts: { + q?: string; + status?: string; + priority?: string; + personId?: string; + assigneeId?: string; + sla?: 'overdue'; + page?: number; + limit?: number; + }, + ): Promise> { + const page = opts.page ?? 1; + const limit = opts.limit ?? 50; + const now = new Date(); + const access = ticketAccessWhere(actor); + const where: Prisma.TicketWhereInput = { + organizationId: actor.organizationId, + ...access, + ...(opts.status ? { status: opts.status as never } : {}), + ...(opts.priority ? { priority: opts.priority as never } : {}), + ...(opts.personId ? { personId: opts.personId } : {}), + ...(opts.assigneeId && elevated(actor.role) + ? { assigneeId: opts.assigneeId } + : {}), + ...(opts.sla === 'overdue' + ? { + slaDueAt: { lt: now }, + firstRespondedAt: null, + status: { in: ['open', 'in_progress', 'waiting'] }, + } + : {}), + ...(opts.q?.trim() + ? { + AND: [ + access, + { + OR: [ + { subject: { contains: opts.q.trim(), mode: 'insensitive' } }, + { number: { contains: opts.q.trim(), mode: 'insensitive' } }, + { + description: { + contains: opts.q.trim(), + mode: 'insensitive', + }, + }, + ], + }, + ], + } + : {}), + }; + + // When q is set, avoid double-spreading access at top level + const finalWhere: Prisma.TicketWhereInput = opts.q?.trim() + ? { + organizationId: actor.organizationId, + ...(opts.status ? { status: opts.status as never } : {}), + ...(opts.priority ? { priority: opts.priority as never } : {}), + ...(opts.personId ? { personId: opts.personId } : {}), + ...(opts.assigneeId && elevated(actor.role) + ? { assigneeId: opts.assigneeId } + : {}), + ...(opts.sla === 'overdue' + ? { + slaDueAt: { lt: now }, + firstRespondedAt: null, + status: { in: ['open', 'in_progress', 'waiting'] }, + } + : {}), + AND: [ + access, + { + OR: [ + { subject: { contains: opts.q.trim(), mode: 'insensitive' } }, + { number: { contains: opts.q.trim(), mode: 'insensitive' } }, + { + description: { + contains: opts.q.trim(), + mode: 'insensitive', + }, + }, + ], + }, + ], + } + : where; + + const [rows, total] = await this.prisma.$transaction([ + this.prisma.ticket.findMany({ + where: finalWhere, + include, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.ticket.count({ where: finalWhere }), + ]); + + return { + items: rows.map((row) => this.toDto(row)), + total, + page, + limit, + }; + } + + async get(actor: Actor, id: string): Promise { + const row = await this.prisma.ticket.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...ticketAccessWhere(actor), + }, + include, + }); + if (!row) throw new NotFoundException('تیکت پیدا نشد'); + return this.toDto(row); + } + + async create(actor: Actor, dto: CreateTicketRequest): Promise { + const personId = emptyToNull(dto.personId ?? null); + const companyId = emptyToNull(dto.companyId ?? null); + const dealId = emptyToNull(dto.dealId ?? null); + let assigneeId = emptyToNull(dto.assigneeId ?? null) ?? actor.userId; + + await this.assertLinks(actor.organizationId, personId, companyId, dealId); + if (assigneeId) { + await this.assertUser(actor.organizationId, assigneeId); + } + if (!elevated(actor.role) && assigneeId && assigneeId !== actor.userId) { + assigneeId = actor.userId; + } + + const number = await this.nextNumber(actor.organizationId); + const status = (dto.status as never) || 'open'; + const priority = ((dto.priority as TicketPriority) || 'normal') as TicketPriority; + const createdAt = new Date(); + const row = await this.prisma.ticket.create({ + data: { + organizationId: actor.organizationId, + number, + subject: dto.subject.trim(), + description: emptyToNull(dto.description ?? null), + status, + priority: priority as never, + personId, + companyId, + dealId, + assigneeId, + reporterId: actor.userId, + createdAt, + slaDueAt: computeSlaDueAt(createdAt, priority), + resolvedAt: + status === 'resolved' || status === 'closed' ? createdAt : null, + ...(dto.description?.trim() + ? { + messages: { + create: { + authorId: actor.userId, + body: dto.description.trim(), + isInternal: false, + }, + }, + } + : {}), + }, + include, + }); + + if (assigneeId && assigneeId !== actor.userId) { + await this.notifications.create({ + organizationId: actor.organizationId, + userId: assigneeId, + type: 'ticket_assigned', + title: 'تیکت جدید ارجاع شد', + body: row.subject, + link: `/tickets/${row.id}`, + entityType: 'ticket', + entityId: row.id, + }); + } + + return this.toDto(row); + } + + async update( + actor: Actor, + id: string, + dto: UpdateTicketRequest, + ): Promise { + const existing = await this.prisma.ticket.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...ticketAccessWhere(actor), + }, + }); + if (!existing) throw new NotFoundException('تیکت پیدا نشد'); + + if ( + 'personId' in dto || + 'companyId' in dto || + 'dealId' in dto + ) { + await this.assertLinks( + actor.organizationId, + emptyToNull(dto.personId ?? null), + emptyToNull(dto.companyId ?? null), + emptyToNull(dto.dealId ?? null), + ); + } + + const data: Prisma.TicketUncheckedUpdateInput = {}; + if (dto.subject !== undefined) data.subject = dto.subject.trim(); + if ('description' in dto) { + data.description = emptyToNull(dto.description ?? null); + } + if (dto.status !== undefined) { + data.status = dto.status as never; + if (dto.status === 'resolved' || dto.status === 'closed') { + data.resolvedAt = existing.resolvedAt ?? new Date(); + } else { + data.resolvedAt = null; + } + } + if (dto.priority !== undefined) { + data.priority = dto.priority as never; + if (!existing.firstRespondedAt) { + data.slaDueAt = computeSlaDueAt( + existing.createdAt, + dto.priority as TicketPriority, + ); + } + } + 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 next = emptyToNull(dto.assigneeId ?? null); + if (next) await this.assertUser(actor.organizationId, next); + if (!elevated(actor.role) && next && next !== actor.userId) { + throw new BadRequestException('فقط می‌توانید تیکت را به خودتان ارجاع دهید'); + } + data.assigneeId = next; + if (next && next !== existing.assigneeId && next !== actor.userId) { + await this.notifications.create({ + organizationId: actor.organizationId, + userId: next, + type: 'ticket_assigned', + title: 'تیکت به شما ارجاع شد', + body: existing.subject, + link: `/tickets/${existing.id}`, + entityType: 'ticket', + entityId: existing.id, + }); + } + } + + const nextStatus = (dto.status ?? existing.status) as string; + if ('csatScore' in dto || 'csatComment' in dto) { + if (nextStatus !== 'resolved' && nextStatus !== 'closed') { + throw new BadRequestException( + 'ثبت رضایت فقط برای تیکت حل‌شده یا بسته مجاز است', + ); + } + if (dto.csatScore !== undefined && dto.csatScore !== null) { + if (dto.csatScore < 1 || dto.csatScore > 5) { + throw new BadRequestException('امتیاز رضایت باید بین ۱ تا ۵ باشد'); + } + data.csatScore = dto.csatScore; + data.csatAt = new Date(); + } + if ('csatComment' in dto) { + data.csatComment = emptyToNull(dto.csatComment ?? null); + if (!data.csatAt && (dto.csatComment || existing.csatScore)) { + data.csatAt = new Date(); + } + } + } + + await this.prisma.ticket.update({ where: { id }, data }); + return this.get(actor, id); + } + + async remove(actor: Actor, id: string): Promise { + await this.ensureExists(actor, id); + await this.prisma.ticket.delete({ where: { id } }); + } + + async addMessage( + actor: Actor, + ticketId: string, + dto: CreateTicketMessageRequest, + ): Promise { + const existing = await this.prisma.ticket.findFirst({ + where: { + id: ticketId, + organizationId: actor.organizationId, + ...ticketAccessWhere(actor), + }, + select: { + id: true, + firstRespondedAt: true, + reporterId: true, + }, + }); + if (!existing) throw new NotFoundException('تیکت پیدا نشد'); + + const isInternal = dto.isInternal ?? false; + await this.prisma.ticketMessage.create({ + data: { + ticketId, + authorId: actor.userId, + body: dto.body.trim(), + isInternal, + }, + }); + + const patch: Prisma.TicketUncheckedUpdateInput = { updatedAt: new Date() }; + if ( + !isInternal && + !existing.firstRespondedAt && + actor.userId !== existing.reporterId + ) { + patch.firstRespondedAt = new Date(); + } + await this.prisma.ticket.update({ where: { id: ticketId }, data: patch }); + return this.get(actor, ticketId); + } + + private async nextNumber(organizationId: string): Promise { + const count = await this.prisma.ticket.count({ where: { organizationId } }); + return `TK-${String(count + 1).padStart(4, '0')}`; + } + + private async ensureExists(actor: Actor, id: string) { + const found = await this.prisma.ticket.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...ticketAccessWhere(actor), + }, + select: { id: true }, + }); + if (!found) throw new NotFoundException('تیکت پیدا نشد'); + } + + private async assertUser(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, + personId: string | null, + companyId: string | null, + dealId: 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('معامله معتبر نیست'); + } + } + + private toDto(row: TicketRow): TicketDto { + const openStatuses = new Set(['open', 'in_progress', 'waiting']); + const slaBreached = Boolean( + row.slaDueAt && + !row.firstRespondedAt && + openStatuses.has(row.status) && + row.slaDueAt.getTime() < Date.now(), + ); + return { + id: row.id, + number: row.number, + subject: row.subject, + description: row.description, + status: row.status, + priority: row.priority, + 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, + reporter: row.reporter, + resolvedAt: row.resolvedAt?.toISOString() ?? null, + slaDueAt: row.slaDueAt?.toISOString() ?? null, + firstRespondedAt: row.firstRespondedAt?.toISOString() ?? null, + slaBreached, + csatScore: row.csatScore, + csatComment: row.csatComment, + csatAt: row.csatAt?.toISOString() ?? null, + messages: row.messages.map((m) => ({ + id: m.id, + body: m.body, + isInternal: m.isInternal, + author: m.author, + createdAt: m.createdAt.toISOString(), + })), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} + +function computeSlaDueAt(from: Date, priority: TicketPriority): Date { + const hours = TICKET_SLA_HOURS[priority] ?? TICKET_SLA_HOURS.normal; + return new Date(from.getTime() + hours * 60 * 60 * 1000); +} + +function emptyToNull(value?: string | null): string | null { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts new file mode 100644 index 0000000..ba56da5 --- /dev/null +++ b/apps/api/src/users/dto.ts @@ -0,0 +1,33 @@ +import { + IsEmail, + IsIn, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +const INVITE_ROLES = ['admin', 'sales', 'support'] as const; +const ALL_ROLES = ['owner', 'admin', 'sales', 'support'] as const; + +export class InviteUserDto { + @IsEmail() + email!: string; + + @IsString() + @MinLength(2) + @MaxLength(120) + fullName!: string; + + @IsString() + @MinLength(8) + @MaxLength(72) + password!: string; + + @IsIn(INVITE_ROLES) + role!: (typeof INVITE_ROLES)[number]; +} + +export class UpdateUserRoleDto { + @IsIn(ALL_ROLES) + role!: (typeof ALL_ROLES)[number]; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..2c347ec --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,51 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import type { UserRole } from '@mento/shared'; +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 { InviteUserDto, UpdateUserRoleDto } from './dto'; +import { UsersService } from './users.service'; + +@Controller('users') +@UseGuards(JwtAuthGuard, RolesGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + list(@CurrentUser() user: { organizationId: string }) { + return this.usersService.list(user.organizationId); + } + + @Post() + @Roles('owner', 'admin') + invite( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Body() dto: InviteUserDto, + ) { + return this.usersService.invite(user.organizationId, user.role, dto); + } + + @Patch(':id/role') + @Roles('owner', 'admin') + updateRole( + @CurrentUser() user: { organizationId: string; userId: string; role: UserRole }, + @Param('id') id: string, + @Body() dto: UpdateUserRoleDto, + ) { + return this.usersService.updateRole( + user.organizationId, + { userId: user.userId, role: user.role }, + id, + dto.role, + ); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..3ad353d --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [AuthModule], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..c3cbb2f --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,113 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async list(organizationId: string): Promise { + const rows = await this.prisma.user.findMany({ + where: { organizationId }, + orderBy: [{ role: 'asc' }, { fullName: 'asc' }], + }); + return rows.map((u) => this.toDto(u)); + } + + async invite( + organizationId: string, + actorRole: UserRole, + dto: InviteUserRequest, + ): Promise { + if (actorRole !== 'owner' && actorRole !== 'admin') { + throw new ForbiddenException('فقط مالک یا مدیر می‌تواند کاربر اضافه کند'); + } + + const email = dto.email.trim().toLowerCase(); + const existing = await this.prisma.user.findFirst({ + where: { organizationId, email }, + }); + if (existing) { + throw new ConflictException('این ایمیل در سازمان شما وجود دارد'); + } + + const global = await this.prisma.user.findFirst({ where: { email } }); + if (global) { + throw new ConflictException('این ایمیل قبلاً در سیستم ثبت شده است'); + } + + const passwordHash = await bcrypt.hash(dto.password, 10); + const row = await this.prisma.user.create({ + data: { + organizationId, + email, + fullName: dto.fullName.trim(), + passwordHash, + role: dto.role, + }, + }); + return this.toDto(row); + } + + async updateRole( + organizationId: string, + actor: { userId: string; role: UserRole }, + targetId: string, + nextRole: UserRole, + ): Promise { + if (actor.role !== 'owner' && actor.role !== 'admin') { + throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد'); + } + + const target = await this.prisma.user.findFirst({ + where: { id: targetId, organizationId }, + }); + if (!target) throw new NotFoundException('کاربر پیدا نشد'); + + if (actor.role === 'admin') { + if (target.role === 'owner' || nextRole === 'owner') { + throw new ForbiddenException('مدیر نمی‌تواند نقش مالک را تغییر دهد'); + } + } + + if (target.role === 'owner' && nextRole !== 'owner') { + const owners = await this.prisma.user.count({ + where: { organizationId, role: 'owner' }, + }); + if (owners <= 1) { + throw new BadRequestException( + 'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', + ); + } + } + + const row = await this.prisma.user.update({ + where: { id: target.id }, + data: { role: nextRole }, + }); + return this.toDto(row); + } + + private toDto(row: { + id: string; + email: string; + fullName: string; + role: UserRole; + createdAt: Date; + }): OrgUserDto { + return { + id: row.id, + email: row.email, + fullName: row.fullName, + role: row.role, + createdAt: row.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..7d214dd --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true + }, + "include": ["src", "prisma"] +} diff --git a/apps/landing/README.md b/apps/landing/README.md new file mode 100644 index 0000000..d15525d --- /dev/null +++ b/apps/landing/README.md @@ -0,0 +1 @@ +# Landing (Nuxt) — intentionally empty until product is complete and tested diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..e4a8673 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,18 @@ + + + + + + منتو | Mento CRM + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..df7fcd2 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mento/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "lint": "echo \"web lint placeholder\"" + }, + "dependencies": { + "@mento/shared": "*", + "axios": "^1.8.4", + "pinia": "^3.0.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "^5.8.2", + "vite": "^6.2.2", + "vue-tsc": "^2.2.8" + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..69dd87a --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/components/CustomFieldsSection.vue b/apps/web/src/components/CustomFieldsSection.vue new file mode 100644 index 0000000..0b473f6 --- /dev/null +++ b/apps/web/src/components/CustomFieldsSection.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/apps/web/src/components/EntityTimeline.vue b/apps/web/src/components/EntityTimeline.vue new file mode 100644 index 0000000..4574b23 --- /dev/null +++ b/apps/web/src/components/EntityTimeline.vue @@ -0,0 +1,387 @@ + + +