Compare commits
207
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d62e004e90 | ||
|
|
32c722f42a | ||
|
|
f7b07f6b7a | ||
|
|
52c7306e78 | ||
|
|
960e01938d | ||
|
|
3fb5f32ac3 | ||
|
|
0ce2533066 | ||
|
|
bc68582fe2 | ||
|
|
e86841fc60 | ||
|
|
5069647f79 | ||
|
|
008733d338 | ||
|
|
c5708ed59b | ||
|
|
65d66bb331 | ||
|
|
79ba146118 | ||
|
|
02841a5b8c | ||
|
|
a04743c6df | ||
|
|
27635c0752 | ||
|
|
03e1c01023 | ||
|
|
177e5cc31b | ||
|
|
2c7c943802 | ||
|
|
9611803567 | ||
|
|
a494b5308e | ||
|
|
582733e2ea | ||
|
|
6bff9d2888 | ||
|
|
6bc5e1bec7 | ||
|
|
3a8a684aa4 | ||
|
|
831c682eb3 | ||
|
|
2cc81bb596 | ||
|
|
c04582072d | ||
|
|
42f0573a5f | ||
|
|
13ee70764b | ||
|
|
cf326d1ed9 | ||
|
|
5f4cc47c41 | ||
|
|
0e420a443f | ||
|
|
6b4bd0b03e | ||
|
|
910c7f51b0 | ||
|
|
609ce3d006 | ||
|
|
9bed6cac03 | ||
|
|
fd6e79c15d | ||
|
|
9b42fec0a8 | ||
|
|
d00f362376 | ||
|
|
fd8966e13f | ||
|
|
91eb9be1d4 | ||
|
|
ffae63edbf | ||
|
|
cba4ad6a49 | ||
|
|
faac26d07d | ||
|
|
a2b01f787e | ||
|
|
8af78047d9 | ||
|
|
b43ed68e82 | ||
|
|
f4398cdb3e | ||
|
|
17638786a8 | ||
|
|
b3b92abae9 | ||
|
|
b30f68563d | ||
|
|
51ca23b398 | ||
|
|
60d797f184 | ||
|
|
b87ebb22e0 | ||
|
|
792b3d29fa | ||
|
|
0802c2f436 | ||
|
|
37157620e2 | ||
|
|
f27b4c264a | ||
|
|
e82bd2b2df | ||
|
|
fa3fe0d364 | ||
|
|
4e26658092 | ||
|
|
287f88abc0 | ||
|
|
09f05d4ecc | ||
|
|
46a7693ea0 | ||
|
|
e6755d0c2e | ||
|
|
2f3646d085 | ||
|
|
f5b6a47f3c | ||
|
|
678cb87de7 | ||
|
|
d683932d1b | ||
|
|
13668c4ba4 | ||
|
|
aa2f39256a | ||
|
|
b4ffef9b6f | ||
|
|
b3784818fc | ||
|
|
3de2d0fa67 | ||
|
|
2a2728e5ee | ||
|
|
e08f1452ef | ||
|
|
43717392b2 | ||
|
|
bc01384551 | ||
|
|
5074c6d791 | ||
|
|
0d7555f219 | ||
|
|
f71f09c85d | ||
|
|
f88786ef61 | ||
|
|
46dcf296d3 | ||
|
|
c8753a4c4e | ||
|
|
48de552f36 | ||
|
|
71b3a7255a | ||
|
|
df8fac187e | ||
|
|
3296b1c209 | ||
|
|
23c83ffbee | ||
|
|
75ff2c36c4 | ||
|
|
39e2eb24a6 | ||
|
|
8b37525f2f | ||
|
|
e144b2277a | ||
|
|
236f73de93 | ||
|
|
60c16053a4 | ||
|
|
cbfe470f33 | ||
|
|
ac57b8ac33 | ||
|
|
d3fb92a5e2 | ||
|
|
07bdc4a3b6 | ||
|
|
c629729a9a | ||
|
|
beae16437b | ||
|
|
17628722d3 | ||
|
|
c322a24424 | ||
|
|
2e315c9cfd | ||
|
|
f8313c0ea5 | ||
|
|
3b128bd980 | ||
|
|
9f97371391 | ||
|
|
1309da2281 | ||
|
|
989c03b381 | ||
|
|
5784b09232 | ||
|
|
5d5b3a3213 | ||
|
|
ae21d9bbc7 | ||
|
|
e94600a02e | ||
|
|
75b83b7f1b | ||
|
|
d3986da28b | ||
|
|
c7a60c524d | ||
|
|
3c0ed3369e | ||
|
|
a3ed6187e9 | ||
|
|
e1d456ad11 | ||
|
|
b978708487 | ||
|
|
4c8731122b | ||
|
|
f71e78f3a7 | ||
|
|
7c4b24fad6 | ||
|
|
e6fb5c3700 | ||
|
|
298b3316b4 | ||
|
|
58e89246d9 | ||
|
|
5bb8e68521 | ||
|
|
88dc7c5a1c | ||
|
|
83504816c5 | ||
|
|
c20dc0a7ca | ||
|
|
e20b904bbe | ||
|
|
7996fc7f73 | ||
|
|
70bcd15847 | ||
|
|
b0f05b6a27 | ||
|
|
d4972891f1 | ||
|
|
032eb70524 | ||
|
|
2309e2c5ab | ||
|
|
66af7e577c | ||
|
|
125976f3eb | ||
|
|
0670067e7f | ||
|
|
a1655432fc | ||
|
|
589c763ef8 | ||
|
|
d2c82f2115 | ||
|
|
319493171f | ||
|
|
f20206b045 | ||
|
|
32e27d45f7 | ||
|
|
8effd4cbc6 | ||
|
|
7e73ba9936 | ||
|
|
647503810d | ||
|
|
ec5c7639e3 | ||
|
|
3833788aa8 | ||
|
|
9edbb92b13 | ||
|
|
05f205cadf | ||
|
|
ac3ffb8ab2 | ||
|
|
c079295ff4 | ||
|
|
22980e5152 | ||
|
|
c5c903d4b4 | ||
|
|
c06c92b333 | ||
|
|
00f8b91d78 | ||
|
|
55a287563d | ||
|
|
a16c2f0a9b | ||
|
|
6a9e864b07 | ||
|
|
d133c8ca34 | ||
|
|
db65b55660 | ||
|
|
26085b54a9 | ||
|
|
1b160c8cd3 | ||
|
|
499d13d1df | ||
|
|
ef600d149d | ||
|
|
ccddd009e3 | ||
|
|
6463275c3d | ||
|
|
35a618d0af | ||
|
|
011b316b5b | ||
|
|
b7e3d1e290 | ||
|
|
7dc4344e0e | ||
|
|
99df327ded | ||
|
|
230d7b3bf6 | ||
|
|
58c3e25db6 | ||
|
|
75a73559b5 | ||
|
|
41f0025883 | ||
|
|
42db5919e1 | ||
|
|
d53136d91c | ||
|
|
dac0454e69 | ||
|
|
4b335aa7ff | ||
|
|
17fb33b3e7 | ||
|
|
be97a067f9 | ||
|
|
9249302eac | ||
|
|
5d9a572ff8 | ||
|
|
f4cb41d96f | ||
|
|
7a58b9d469 | ||
|
|
1adc217375 | ||
|
|
d7d82102af | ||
|
|
f5a4eb577b | ||
|
|
ebf13cfde5 | ||
|
|
0e580b19c3 | ||
|
|
4a9fe0b07c | ||
|
|
04d37a9d12 | ||
|
|
605e1d3340 | ||
|
|
b016b4754f | ||
|
|
ff5ad21eb9 | ||
|
|
4776d1a3c3 | ||
|
|
9c7dad3677 | ||
|
|
a428c6f135 | ||
|
|
3b643d7bb9 | ||
|
|
dfd35c45ef | ||
|
|
1ae87c97b7 |
+9
-1
@@ -17,11 +17,19 @@ SMS_PASSWORD=
|
||||
SMS_SENDER=
|
||||
# SMS_BASE_URL=http://smspanel.Trez.ir
|
||||
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||
# SMS_OTP_TEMPLATE=کد ورود پرتال منتکت: {code}
|
||||
# SMS_OTP_TEMPLATE=کد ورود منتکت: {code}
|
||||
# SMS_TIMEOUT_MS=20000
|
||||
|
||||
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت بهصورت hex (۶۴ کاراکتر)
|
||||
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
INTEGRATIONS_ENCRYPTION_KEY=
|
||||
|
||||
# سوپرادمین پلتفرم — ورود از /adm با موبایل + رمز
|
||||
# در لوکال اگر خالی بماند: 09120000000 / demo1234
|
||||
PLATFORM_ADMIN_MOBILE=
|
||||
PLATFORM_ADMIN_PASSWORD=
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
# اختیاری
|
||||
# PLATFORM_ADMIN_EMAIL=
|
||||
|
||||
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# فعالسازی: npm install (اسکریپت prepare) یا git config core.hooksPath .githooks
|
||||
npm run check
|
||||
+15
@@ -21,3 +21,18 @@ docs/~$*
|
||||
*.py[cod]
|
||||
apps/stt/.venv/
|
||||
crm-node_modules.tgz
|
||||
|
||||
# Playwright
|
||||
e2e/.auth/
|
||||
e2e/.env
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Graphify (local architecture graph — do not commit)
|
||||
graphify-out/
|
||||
**/graphify-out/
|
||||
.cursor/rules/graphify.mdc
|
||||
|
||||
# Local git worktrees + SDD scratch
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
@@ -80,7 +80,7 @@ $env:MENTO_DEV_API="http://127.0.0.1:3000"; npm run dev
|
||||
|
||||
## فاز فعلی
|
||||
|
||||
فاز ۱ هسته فروش/پشتیبانی و Smart Notes عملاً کامل است. جزئیات تیکخورده و باقیمانده:
|
||||
فاز ۱ هسته فروش/پشتیبانی، Smart Notes، مالی سبک و اتصالها (API، ووکامرس، VoIP، پیامک گروهی) عملاً کامل است. جزئیات تیکخورده و باقیمانده:
|
||||
|
||||
- [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چکلیست زنده**
|
||||
- [`docs/قابلیتهای-منتکت.md`](docs/قابلیتهای-منتکت.md) ← فهرست کوتاه قابلیتها
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"lint": "echo \"api lint placeholder\""
|
||||
"lint": "echo \"api lint placeholder\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "PlatformAdmin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlatformAdmin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_email_key" ON "PlatformAdmin"("email");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlatformAdmin" ADD COLUMN IF NOT EXISTS "mobile" TEXT;
|
||||
|
||||
-- Make email optional (existing rows keep their email)
|
||||
ALTER TABLE "PlatformAdmin" ALTER COLUMN "email" DROP NOT NULL;
|
||||
|
||||
-- Unique mobile
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_mobile_key" ON "PlatformAdmin"("mobile");
|
||||
@@ -0,0 +1,64 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "enabledModules" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "BillingAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ownerUserId" TEXT NOT NULL,
|
||||
"planCode" TEXT NOT NULL DEFAULT 'trial',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"maxOrganizations" INTEGER NOT NULL DEFAULT 1,
|
||||
"externalPaymentRef" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BillingAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "BillingAccount_ownerUserId_key" ON "BillingAccount"("ownerUserId");
|
||||
CREATE INDEX IF NOT EXISTS "BillingAccount_status_idx" ON "BillingAccount"("status");
|
||||
CREATE INDEX IF NOT EXISTS "BillingAccount_expiresAt_idx" ON "BillingAccount"("expiresAt");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'BillingAccount_ownerUserId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "BillingAccount"
|
||||
ADD CONSTRAINT "BillingAccount_ownerUserId_fkey"
|
||||
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Backfill: full module catalog for existing orgs (empty → all)
|
||||
UPDATE "Organization"
|
||||
SET "enabledModules" = ARRAY[
|
||||
'customers','pipeline','products','invoices','quotes','commissions','sales_forecast',
|
||||
'activities','tickets','workflow_automations','message_automations','lead_forms',
|
||||
'project_boards','inventory','cheques','attachments','loyalty','reports','notes',
|
||||
'tahesab','custom_fields','currencies','team_settings','faq'
|
||||
]::TEXT[]
|
||||
WHERE cardinality("enabledModules") = 0;
|
||||
|
||||
-- Backfill BillingAccount for every active owner membership (15-day trial)
|
||||
INSERT INTO "BillingAccount" (
|
||||
"id", "ownerUserId", "planCode", "status", "startsAt", "expiresAt",
|
||||
"maxOrganizations", "createdAt", "updatedAt"
|
||||
)
|
||||
SELECT
|
||||
md5(random()::text || clock_timestamp()::text || m."userId"),
|
||||
m."userId",
|
||||
'trial',
|
||||
'active',
|
||||
NOW(),
|
||||
NOW() + INTERVAL '15 days',
|
||||
GREATEST(1, COUNT(*)::int),
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "Membership" m
|
||||
WHERE m."role" = 'owner' AND m."isActive" = true
|
||||
GROUP BY m."userId"
|
||||
ON CONFLICT ("ownerUserId") DO NOTHING;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductAccountingLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"externalName" TEXT NOT NULL,
|
||||
"ayar" INTEGER,
|
||||
"vaznSabet" DOUBLE PRECISION,
|
||||
"matchMethod" TEXT NOT NULL DEFAULT 'import',
|
||||
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductAccountingLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProductAccountingLink_productId_idx" ON "ProductAccountingLink"("productId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProductAccountingLink_connectionId_idx" ON "ProductAccountingLink"("connectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductAccountingLink_productId_connectionId_key" ON "ProductAccountingLink"("productId", "connectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductAccountingLink_connectionId_externalName_key" ON "ProductAccountingLink"("connectionId", "externalName");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductAccountingLink" ADD CONSTRAINT "ProductAccountingLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "website" TEXT;
|
||||
ALTER TABLE "Person" ADD COLUMN "referrer" TEXT;
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProjectCard" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"actorUserId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"entityType" TEXT,
|
||||
"entityId" TEXT,
|
||||
"summary" TEXT NOT NULL,
|
||||
"meta" JSONB,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "SavedView" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"ownerUserId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"filters" JSONB NOT NULL DEFAULT '{}',
|
||||
"isShared" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable (implicit many-to-many ProjectCard <-> User assignees)
|
||||
CREATE TABLE IF NOT EXISTS "_ProjectCardAssignees" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_createdAt_idx" ON "AuditLog"("organizationId", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_actorUserId_createdAt_idx" ON "AuditLog"("organizationId", "actorUserId", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_entityType_createdAt_idx" ON "AuditLog"("organizationId", "entityType", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_scope_idx" ON "SavedView"("organizationId", "scope");
|
||||
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_ownerUserId_idx" ON "SavedView"("organizationId", "ownerUserId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "_ProjectCardAssignees_AB_unique" ON "_ProjectCardAssignees"("A", "B");
|
||||
CREATE INDEX IF NOT EXISTS "_ProjectCardAssignees_B_index" ON "_ProjectCardAssignees"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "ProjectCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganizationApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"keyHash" TEXT NOT NULL,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "OrganizationApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "WebhookEndpoint" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"events" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "WebhookDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"endpointId" TEXT NOT NULL,
|
||||
"event" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"statusCode" INTEGER,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SmsCampaign" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"scheduledAt" TIMESTAMP(3),
|
||||
"totalCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"sentCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"failedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"skippedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SmsCampaign_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "SmsCampaignRecipient" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"personId" TEXT,
|
||||
"mobile" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"error" TEXT,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SmsCampaignRecipient_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "OrganizationApiKey_keyHash_key" ON "OrganizationApiKey"("keyHash");
|
||||
CREATE INDEX "OrganizationApiKey_organizationId_revokedAt_idx" ON "OrganizationApiKey"("organizationId", "revokedAt");
|
||||
CREATE INDEX "OrganizationApiKey_prefix_idx" ON "OrganizationApiKey"("prefix");
|
||||
CREATE INDEX "WebhookEndpoint_organizationId_isActive_idx" ON "WebhookEndpoint"("organizationId", "isActive");
|
||||
CREATE INDEX "WebhookDelivery_organizationId_createdAt_idx" ON "WebhookDelivery"("organizationId", "createdAt");
|
||||
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
|
||||
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
|
||||
CREATE INDEX "SmsCampaign_organizationId_status_scheduledAt_idx" ON "SmsCampaign"("organizationId", "status", "scheduledAt");
|
||||
CREATE INDEX "SmsCampaignRecipient_campaignId_status_idx" ON "SmsCampaignRecipient"("campaignId", "status");
|
||||
|
||||
ALTER TABLE "OrganizationApiKey" ADD CONSTRAINT "OrganizationApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "WebhookEndpoint" ADD CONSTRAINT "WebhookEndpoint_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsCampaign" ADD CONSTRAINT "SmsCampaign_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "SmsCampaignRecipient" ADD CONSTRAINT "SmsCampaignRecipient_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "SmsCampaign"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "dataVisibility" TEXT NOT NULL DEFAULT 'own';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'member',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_organizationId_idx" ON "Team"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Team_organizationId_name_key" ON "Team"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "WooCommerceConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"storeUrl" TEXT NOT NULL,
|
||||
"consumerKeyCiphertext" TEXT NOT NULL,
|
||||
"consumerKeyIv" TEXT NOT NULL,
|
||||
"consumerKeyTag" TEXT NOT NULL,
|
||||
"consumerKeyLast4" TEXT NOT NULL DEFAULT '',
|
||||
"consumerSecretCiphertext" TEXT NOT NULL,
|
||||
"consumerSecretIv" TEXT NOT NULL,
|
||||
"consumerSecretTag" TEXT NOT NULL,
|
||||
"webhookSecretCiphertext" TEXT,
|
||||
"webhookSecretIv" TEXT,
|
||||
"webhookSecretTag" TEXT,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"lastHealthAt" TIMESTAMP(3),
|
||||
"lastHealthOk" BOOLEAN,
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WooCommerceConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WooCommerceOrderLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"wooOrderId" TEXT NOT NULL,
|
||||
"wooOrderNumber" TEXT,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT '',
|
||||
"total" DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
"currency" TEXT NOT NULL DEFAULT 'IRR',
|
||||
"rawPayload" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WooCommerceOrderLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WooCommerceConnection_organizationId_enabled_idx" ON "WooCommerceConnection"("organizationId", "enabled");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WooCommerceOrderLink_organizationId_syncedAt_idx" ON "WooCommerceOrderLink"("organizationId", "syncedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WooCommerceOrderLink_invoiceId_idx" ON "WooCommerceOrderLink"("invoiceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WooCommerceOrderLink_connectionId_wooOrderId_key" ON "WooCommerceOrderLink"("connectionId", "wooOrderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WooCommerceConnection" ADD CONSTRAINT "WooCommerceConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WooCommerceOrderLink" ADD CONSTRAINT "WooCommerceOrderLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "WooCommerceConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "VoipConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"webhookSecretCiphertext" TEXT,
|
||||
"webhookSecretIv" TEXT,
|
||||
"webhookSecretTag" TEXT,
|
||||
"clickToCallUrl" TEXT,
|
||||
"lastEventAt" TIMESTAMP(3),
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VoipConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VoipCallLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"externalCallId" TEXT NOT NULL,
|
||||
"direction" TEXT NOT NULL DEFAULT 'inbound',
|
||||
"status" TEXT NOT NULL DEFAULT 'completed',
|
||||
"fromNumber" TEXT,
|
||||
"toNumber" TEXT,
|
||||
"personId" TEXT,
|
||||
"activityId" TEXT,
|
||||
"durationSec" INTEGER,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
"rawPayload" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VoipCallLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipConnection_organizationId_enabled_idx" ON "VoipConnection"("organizationId", "enabled");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_organizationId_syncedAt_idx" ON "VoipCallLog"("organizationId", "syncedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_personId_idx" ON "VoipCallLog"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_activityId_idx" ON "VoipCallLog"("activityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VoipCallLog_connectionId_externalCallId_key" ON "VoipCallLog"("connectionId", "externalCallId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VoipConnection" ADD CONSTRAINT "VoipConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VoipCallLog" ADD CONSTRAINT "VoipCallLog_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "VoipConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ReservationService" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"durationMinutes" INTEGER NOT NULL DEFAULT 30,
|
||||
"workDays" INTEGER[] DEFAULT ARRAY[0, 1, 2, 3, 4, 5, 6]::INTEGER[],
|
||||
"dayStartHour" INTEGER NOT NULL DEFAULT 9,
|
||||
"dayEndHour" INTEGER NOT NULL DEFAULT 17,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ReservationService_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ReservationAppointment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"serviceId" TEXT NOT NULL,
|
||||
"personId" TEXT NOT NULL,
|
||||
"assigneeId" TEXT,
|
||||
"startsAt" TIMESTAMP(3) NOT NULL,
|
||||
"endsAt" TIMESTAMP(3) NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ReservationAppointment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReservationService_organizationId_isActive_idx" ON "ReservationService"("organizationId", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReservationAppointment_organizationId_startsAt_idx" ON "ReservationAppointment"("organizationId", "startsAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReservationAppointment_organizationId_status_idx" ON "ReservationAppointment"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReservationAppointment_serviceId_startsAt_idx" ON "ReservationAppointment"("serviceId", "startsAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReservationAppointment_personId_idx" ON "ReservationAppointment"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReservationAppointment_assigneeId_startsAt_idx" ON "ReservationAppointment"("assigneeId", "startsAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReservationService" ADD CONSTRAINT "ReservationService_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ReservationService"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReservationAppointment" ADD CONSTRAINT "ReservationAppointment_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Note" ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'team';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Note_organizationId_visibility_idx" ON "Note"("organizationId", "visibility");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoteRevision" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"noteId" TEXT NOT NULL,
|
||||
"editorId" TEXT,
|
||||
"title" TEXT,
|
||||
"body" TEXT NOT NULL,
|
||||
"noteType" TEXT NOT NULL,
|
||||
"visibility" TEXT NOT NULL DEFAULT 'team',
|
||||
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"checklist" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NoteRevision_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoteRevision_noteId_createdAt_idx" ON "NoteRevision"("noteId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoteRevision_organizationId_idx" ON "NoteRevision"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoteRevision_editorId_idx" ON "NoteRevision"("editorId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteRevision" ADD CONSTRAINT "NoteRevision_editorId_fkey" FOREIGN KEY ("editorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "_DealAssignees" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_DealAssignees_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_DealAssignees_B_index" ON "_DealAssignees"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "recordingUrl" TEXT;
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "transcript" TEXT;
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "summary" TEXT;
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryStatus" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNoteId" TEXT;
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryError" TEXT;
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNextAttemptAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_summaryStatus_summaryNextAttemptAt_idx" ON "VoipCallLog"("summaryStatus", "summaryNextAttemptAt");
|
||||
@@ -0,0 +1,113 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LeadStage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"color" TEXT NOT NULL DEFAULT 'teal',
|
||||
"showOnBoard" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isConverted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isLost" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isUnqualified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LeadStage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Lead" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"contactName" TEXT,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"phone" TEXT,
|
||||
"city" TEXT,
|
||||
"region" TEXT,
|
||||
"trade" TEXT,
|
||||
"source" TEXT,
|
||||
"notes" TEXT,
|
||||
"estimatedValue" DECIMAL(18,2),
|
||||
"stageId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"assigneeId" TEXT,
|
||||
"createdById" TEXT,
|
||||
"lastContactAt" TIMESTAMP(3),
|
||||
"nextFollowUpAt" TIMESTAMP(3),
|
||||
"convertedPersonId" TEXT,
|
||||
"convertedAt" TIMESTAMP(3),
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LeadEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"leadId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT,
|
||||
"meta" JSONB,
|
||||
"actorId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LeadEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LeadImportJob" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"fileName" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"totalCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"processedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"successCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"duplicateCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"errorCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"rows" JSONB NOT NULL DEFAULT '[]',
|
||||
"errorSamples" JSONB NOT NULL DEFAULT '[]',
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"finishedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LeadImportJob_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LeadStage_organizationId_position_idx" ON "LeadStage"("organizationId", "position");
|
||||
CREATE UNIQUE INDEX "LeadStage_organizationId_name_key" ON "LeadStage"("organizationId", "name");
|
||||
|
||||
CREATE UNIQUE INDEX "Lead_organizationId_mobile_key" ON "Lead"("organizationId", "mobile");
|
||||
CREATE INDEX "Lead_organizationId_stageId_position_idx" ON "Lead"("organizationId", "stageId", "position");
|
||||
CREATE INDEX "Lead_organizationId_assigneeId_idx" ON "Lead"("organizationId", "assigneeId");
|
||||
CREATE INDEX "Lead_organizationId_nextFollowUpAt_idx" ON "Lead"("organizationId", "nextFollowUpAt");
|
||||
CREATE INDEX "Lead_organizationId_lastContactAt_idx" ON "Lead"("organizationId", "lastContactAt");
|
||||
CREATE INDEX "Lead_organizationId_archivedAt_idx" ON "Lead"("organizationId", "archivedAt");
|
||||
CREATE INDEX "Lead_organizationId_createdAt_idx" ON "Lead"("organizationId", "createdAt");
|
||||
CREATE INDEX "Lead_organizationId_city_idx" ON "Lead"("organizationId", "city");
|
||||
CREATE INDEX "Lead_organizationId_trade_idx" ON "Lead"("organizationId", "trade");
|
||||
CREATE INDEX "Lead_organizationId_source_idx" ON "Lead"("organizationId", "source");
|
||||
|
||||
CREATE INDEX "LeadEvent_leadId_createdAt_idx" ON "LeadEvent"("leadId", "createdAt");
|
||||
CREATE INDEX "LeadEvent_organizationId_createdAt_idx" ON "LeadEvent"("organizationId", "createdAt");
|
||||
|
||||
CREATE INDEX "LeadImportJob_organizationId_status_createdAt_idx" ON "LeadImportJob"("organizationId", "status", "createdAt");
|
||||
CREATE INDEX "LeadImportJob_status_createdAt_idx" ON "LeadImportJob"("status", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LeadStage" ADD CONSTRAINT "LeadStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "LeadStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "LeadEvent" ADD CONSTRAINT "LeadEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "LeadImportJob" ADD CONSTRAINT "LeadImportJob_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "logoStorageKey" TEXT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable LeadForm: publish → Lead connection
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "createLead" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadStageId" TEXT;
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateMode" TEXT NOT NULL DEFAULT 'update';
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "viewCount" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "startCount" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitCount" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "leadCreateCount" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "duplicateCount" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Existing forms: keep person/deal behavior unless already lead-oriented
|
||||
ALTER TABLE "LeadForm" ALTER COLUMN "createPerson" SET DEFAULT false;
|
||||
ALTER TABLE "LeadForm" ALTER COLUMN "createDeal" SET DEFAULT false;
|
||||
|
||||
-- AlterTable LeadFormSubmission
|
||||
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "leadId" TEXT;
|
||||
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'created';
|
||||
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "utm" JSONB;
|
||||
ALTER TABLE "LeadFormSubmission" ADD COLUMN IF NOT EXISTS "meta" JSONB;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "LeadFormSubmission_leadId_idx" ON "LeadFormSubmission"("leadId");
|
||||
@@ -0,0 +1,45 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationSettings" (
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"chatHost" TEXT NOT NULL,
|
||||
"apiKeyCipher" TEXT NOT NULL,
|
||||
"apiKeyIv" TEXT NOT NULL,
|
||||
"apiKeyTag" TEXT NOT NULL,
|
||||
"uploadUrl" TEXT NOT NULL DEFAULT '',
|
||||
"downloadUrl" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ConversationSettings_pkey" PRIMARY KEY ("organizationId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationRoom" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"dmUserMinId" TEXT,
|
||||
"dmUserMaxId" TEXT,
|
||||
"createdByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ConversationRoom_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationRoom_organizationId_externalRoomId_key" ON "ConversationRoom"("organizationId", "externalRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationRoom_organizationId_dmUserMinId_dmUserMaxId_key" ON "ConversationRoom"("organizationId", "dmUserMinId", "dmUserMaxId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConversationRoom_organizationId_kind_idx" ON "ConversationRoom"("organizationId", "kind");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationSettings" ADD CONSTRAINT "ConversationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationRoom" ADD CONSTRAINT "ConversationRoom_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationHiddenMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"messageId" TEXT NOT NULL,
|
||||
"hiddenByUserId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ConversationHiddenMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_idx" ON "ConversationHiddenMessage"("organizationId", "externalRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationHiddenMessage" ADD CONSTRAINT "ConversationHiddenMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ConversationHiddenMessage" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'all';
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationHiddenMessage_organizationId_externalRoomId_messageId_scope_hiddenByUserId_key" ON "ConversationHiddenMessage"("organizationId", "externalRoomId", "messageId", "scope", "hiddenByUserId");
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationPinnedMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"messageId" TEXT NOT NULL,
|
||||
"preview" TEXT NOT NULL DEFAULT '',
|
||||
"pinnedByUserId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ConversationPinnedMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationRoomUserPin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ConversationRoomUserPin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_key" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConversationPinnedMessage_organizationId_externalRoomId_idx" ON "ConversationPinnedMessage"("organizationId", "externalRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationRoomUserPin_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserPin"("organizationId", "userId", "externalRoomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConversationRoomUserPin_organizationId_userId_idx" ON "ConversationRoomUserPin"("organizationId", "userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationPinnedMessage" ADD CONSTRAINT "ConversationPinnedMessage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationRoomUserPin" ADD CONSTRAINT "ConversationRoomUserPin_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConversationRoomMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'member',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ConversationRoomMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConversationRoomMember_organizationId_externalRoomId_userId_key" ON "ConversationRoomMember"("organizationId", "externalRoomId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConversationRoomMember_organizationId_externalRoomId_idx" ON "ConversationRoomMember"("organizationId", "externalRoomId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConversationRoomMember" ADD CONSTRAINT "ConversationRoomMember_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Mute per-user (smart: mentions can still notify)
|
||||
CREATE TABLE "ConversationRoomUserMute" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"allowMentions" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ConversationRoomUserMute_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ConversationRoomUserMute_organizationId_userId_externalRoomId_key" ON "ConversationRoomUserMute"("organizationId", "userId", "externalRoomId");
|
||||
CREATE INDEX "ConversationRoomUserMute_organizationId_userId_idx" ON "ConversationRoomUserMute"("organizationId", "userId");
|
||||
ALTER TABLE "ConversationRoomUserMute" ADD CONSTRAINT "ConversationRoomUserMute_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Message emoji reactions
|
||||
CREATE TABLE "ConversationMessageReaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"externalRoomId" TEXT NOT NULL,
|
||||
"messageId" TEXT NOT NULL,
|
||||
"emoji" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ConversationMessageReaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ConversationMessageReaction_organizationId_externalRoomId_messageId_userId_emoji_key" ON "ConversationMessageReaction"("organizationId", "externalRoomId", "messageId", "userId", "emoji");
|
||||
CREATE INDEX "ConversationMessageReaction_organizationId_externalRoomId_idx" ON "ConversationMessageReaction"("organizationId", "externalRoomId");
|
||||
ALTER TABLE "ConversationMessageReaction" ADD CONSTRAINT "ConversationMessageReaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Extend AutomationTrigger
|
||||
CREATE TYPE "AutomationTrigger_new" AS ENUM (
|
||||
'person_created',
|
||||
'lead_submitted',
|
||||
'deal_created',
|
||||
'deal_stage_changed',
|
||||
'deal_won',
|
||||
'deal_lost',
|
||||
'ticket_created',
|
||||
'ticket_resolved',
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_done',
|
||||
'sms_link_clicked'
|
||||
);
|
||||
|
||||
ALTER TABLE "AutomationRule"
|
||||
ALTER COLUMN "trigger" TYPE "AutomationTrigger_new"
|
||||
USING ("trigger"::text::"AutomationTrigger_new");
|
||||
|
||||
ALTER TYPE "AutomationTrigger" RENAME TO "AutomationTrigger_old";
|
||||
ALTER TYPE "AutomationTrigger_new" RENAME TO "AutomationTrigger";
|
||||
DROP TYPE "AutomationTrigger_old";
|
||||
|
||||
-- Extend MessageAutomationTrigger
|
||||
CREATE TYPE "MessageAutomationTrigger_new" AS ENUM (
|
||||
'person_created',
|
||||
'lead_submitted',
|
||||
'deal_created',
|
||||
'deal_stage_changed',
|
||||
'deal_won',
|
||||
'deal_lost',
|
||||
'ticket_created',
|
||||
'ticket_resolved',
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_created',
|
||||
'activity_done',
|
||||
'sms_link_clicked'
|
||||
);
|
||||
|
||||
ALTER TABLE "MessageAutomationRule"
|
||||
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
|
||||
USING ("trigger"::text::"MessageAutomationTrigger_new");
|
||||
|
||||
ALTER TABLE "MessageAutomationJob"
|
||||
ALTER COLUMN "trigger" TYPE "MessageAutomationTrigger_new"
|
||||
USING ("trigger"::text::"MessageAutomationTrigger_new");
|
||||
|
||||
ALTER TYPE "MessageAutomationTrigger" RENAME TO "MessageAutomationTrigger_old";
|
||||
ALTER TYPE "MessageAutomationTrigger_new" RENAME TO "MessageAutomationTrigger";
|
||||
DROP TYPE "MessageAutomationTrigger_old";
|
||||
|
||||
-- Recipient: optional leadId for future Lead campaigns
|
||||
ALTER TABLE "SmsCampaignRecipient" ADD COLUMN "leadId" TEXT;
|
||||
CREATE INDEX "SmsCampaignRecipient_personId_idx" ON "SmsCampaignRecipient"("personId");
|
||||
CREATE INDEX "SmsCampaignRecipient_leadId_idx" ON "SmsCampaignRecipient"("leadId");
|
||||
|
||||
-- TrackingLink
|
||||
CREATE TABLE "TrackingLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"originalUrl" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL DEFAULT 'sms',
|
||||
"sourceType" TEXT NOT NULL,
|
||||
"sourceId" TEXT NOT NULL,
|
||||
"recipientType" TEXT,
|
||||
"recipientId" TEXT,
|
||||
"smsRecipientId" TEXT,
|
||||
"personId" TEXT,
|
||||
"leadId" TEXT,
|
||||
"clickCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"firstClickedAt" TIMESTAMP(3),
|
||||
"lastClickedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TrackingLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "TrackingLink_token_key" ON "TrackingLink"("token");
|
||||
CREATE INDEX "TrackingLink_organizationId_sourceType_sourceId_idx" ON "TrackingLink"("organizationId", "sourceType", "sourceId");
|
||||
CREATE INDEX "TrackingLink_organizationId_personId_idx" ON "TrackingLink"("organizationId", "personId");
|
||||
CREATE INDEX "TrackingLink_organizationId_leadId_idx" ON "TrackingLink"("organizationId", "leadId");
|
||||
CREATE INDEX "TrackingLink_smsRecipientId_idx" ON "TrackingLink"("smsRecipientId");
|
||||
CREATE INDEX "TrackingLink_token_idx" ON "TrackingLink"("token");
|
||||
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_smsRecipientId_fkey" FOREIGN KEY ("smsRecipientId") REFERENCES "SmsCampaignRecipient"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingLink" ADD CONSTRAINT "TrackingLink_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- TrackingEvent
|
||||
CREATE TABLE "TrackingEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"trackingLinkId" TEXT,
|
||||
"eventType" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL DEFAULT 'sms',
|
||||
"sourceType" TEXT,
|
||||
"sourceId" TEXT,
|
||||
"recipientType" TEXT,
|
||||
"recipientId" TEXT,
|
||||
"personId" TEXT,
|
||||
"leadId" TEXT,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"metadata" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TrackingEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "TrackingEvent_organizationId_eventType_occurredAt_idx" ON "TrackingEvent"("organizationId", "eventType", "occurredAt");
|
||||
CREATE INDEX "TrackingEvent_organizationId_sourceType_sourceId_eventType_idx" ON "TrackingEvent"("organizationId", "sourceType", "sourceId", "eventType");
|
||||
CREATE INDEX "TrackingEvent_organizationId_personId_eventType_idx" ON "TrackingEvent"("organizationId", "personId", "eventType");
|
||||
CREATE INDEX "TrackingEvent_organizationId_leadId_eventType_idx" ON "TrackingEvent"("organizationId", "leadId", "eventType");
|
||||
CREATE INDEX "TrackingEvent_trackingLinkId_occurredAt_idx" ON "TrackingEvent"("trackingLinkId", "occurredAt");
|
||||
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_trackingLinkId_fkey" FOREIGN KEY ("trackingLinkId") REFERENCES "TrackingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "TrackingEvent" ADD CONSTRAINT "TrackingEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "instagram" TEXT;
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "addresses" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "instagram" TEXT;
|
||||
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "addresses" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,17 @@
|
||||
-- OrgRole hierarchy (parent / child sub-roles)
|
||||
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "OrgRole_parentId_idx" ON "OrgRole"("parentId");
|
||||
CREATE INDEX IF NOT EXISTS "OrgRole_organizationId_parentId_idx" ON "OrgRole"("organizationId", "parentId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'OrgRole_parentId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "OrgRole"
|
||||
ADD CONSTRAINT "OrgRole_parentId_fkey"
|
||||
FOREIGN KEY ("parentId") REFERENCES "OrgRole"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Multi-assignee support for activities (implicit many-to-many)
|
||||
CREATE TABLE IF NOT EXISTS "_ActivityAssignees" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "_ActivityAssignees_AB_unique" ON "_ActivityAssignees"("A", "B");
|
||||
CREATE INDEX IF NOT EXISTS "_ActivityAssignees_B_index" ON "_ActivityAssignees"("B");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_A_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "_ActivityAssignees"
|
||||
ADD CONSTRAINT "_ActivityAssignees_A_fkey"
|
||||
FOREIGN KEY ("A") REFERENCES "Activity"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = '_ActivityAssignees_B_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "_ActivityAssignees"
|
||||
ADD CONSTRAINT "_ActivityAssignees_B_fkey"
|
||||
FOREIGN KEY ("B") REFERENCES "User"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Backfill from legacy single assigneeId
|
||||
INSERT INTO "_ActivityAssignees" ("A", "B")
|
||||
SELECT a."id", a."assigneeId"
|
||||
FROM "Activity" a
|
||||
WHERE a."assigneeId" IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousFirstName" TEXT;
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousLastName" TEXT;
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Person_organizationId_importSource_idx" ON "Person"("organizationId", "importSource");
|
||||
CREATE INDEX IF NOT EXISTS "Person_organizationId_sourceRef_idx" ON "Person"("organizationId", "sourceRef");
|
||||
|
||||
-- Backfill from legacy customFields / notes
|
||||
UPDATE "Person"
|
||||
SET
|
||||
"importSource" = 'didar',
|
||||
"sourceRef" = COALESCE(NULLIF(BTRIM("sourceRef"), ''), NULLIF(BTRIM("customFields"->>'didarId'), ''))
|
||||
WHERE COALESCE(BTRIM("customFields"->>'didarId'), '') <> '';
|
||||
|
||||
UPDATE "Person"
|
||||
SET "importSource" = 'tahesab'
|
||||
WHERE "importSource" = 'manual'
|
||||
AND notes ILIKE '%تهحساب%';
|
||||
|
||||
UPDATE "Person"
|
||||
SET "importSource" = 'excel'
|
||||
WHERE "importSource" = 'manual'
|
||||
AND COALESCE(BTRIM("customFields"->>'customerCode'), '') <> ''
|
||||
AND COALESCE(BTRIM("customFields"->>'didarId'), '') = '';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "UserTablePreference" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"tableKey" TEXT NOT NULL,
|
||||
"hiddenColumns" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserTablePreference_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_tableKey_key" ON "UserTablePreference"("userId", "organizationId", "tableKey");
|
||||
CREATE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_idx" ON "UserTablePreference"("userId", "organizationId");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "UserTablePreference" ADD CONSTRAINT "UserTablePreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousImportSource" TEXT;
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "previousSourceRef" TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Person_organizationId_previousSourceRef_idx" ON "Person"("organizationId", "previousSourceRef");
|
||||
|
||||
-- اشخاصی که قبلاً از دیدار آمدهاند و بعد با تهحساب بازنویسی شدهاند: کد دیدار را بهعنوان کد قبلی بازیابی کن
|
||||
UPDATE "Person"
|
||||
SET "previousImportSource" = 'didar',
|
||||
"previousSourceRef" = TRIM("customFields"->>'didarId')
|
||||
WHERE "importSource" = 'tahesab'
|
||||
AND "previousSourceRef" IS NULL
|
||||
AND "customFields" IS NOT NULL
|
||||
AND jsonb_typeof("customFields") = 'object'
|
||||
AND COALESCE(TRIM("customFields"->>'didarId'), '') <> '';
|
||||
@@ -0,0 +1,33 @@
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "code" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "economicCode" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registrationNumber" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherPhones" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "fax" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "otherEmails" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "province" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "city" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "postalCode" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "groupName" TEXT;
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "registeredAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Company_organizationId_sourceRef_idx" ON "Company"("organizationId", "sourceRef");
|
||||
CREATE INDEX IF NOT EXISTS "Company_organizationId_code_idx" ON "Company"("organizationId", "code");
|
||||
|
||||
-- شرکتهایی که از ورود اشخاص دیدار ساخته شدهاند: کد دیدار شرکت را از customFields اشخاص بازیابی کن
|
||||
UPDATE "Company" c
|
||||
SET "sourceRef" = sub.didar_code,
|
||||
"importSource" = 'didar'
|
||||
FROM (
|
||||
SELECT DISTINCT ON (p."companyId")
|
||||
p."companyId" AS company_id,
|
||||
TRIM(p."customFields"->>'companyDidarId') AS didar_code
|
||||
FROM "Person" p
|
||||
WHERE p."companyId" IS NOT NULL
|
||||
AND jsonb_typeof(p."customFields") = 'object'
|
||||
AND COALESCE(TRIM(p."customFields"->>'companyDidarId'), '') <> ''
|
||||
ORDER BY p."companyId", p."createdAt"
|
||||
) sub
|
||||
WHERE c."id" = sub.company_id
|
||||
AND c."sourceRef" IS NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "reminderSentAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Activity_reminderAt_reminderSentAt_idx" ON "Activity"("reminderAt", "reminderSentAt");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataVisibilityScopes" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "code" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "probability" INTEGER;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceDetail" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "referrer" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "lostNote" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "closedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "createdById" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Deal_organizationId_sourceRef_idx" ON "Deal"("organizationId", "sourceRef");
|
||||
|
||||
UPDATE "Deal"
|
||||
SET "importSource" = 'didar', "sourceRef" = "customFields"->>'didarId'
|
||||
WHERE "sourceRef" IS NULL AND COALESCE("customFields"->>'didarId', '') <> '';
|
||||
@@ -0,0 +1,162 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "MontifyConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"baseUrl" TEXT NOT NULL,
|
||||
"storeId" TEXT NOT NULL DEFAULT '',
|
||||
"tokenCiphertext" TEXT NOT NULL,
|
||||
"tokenIv" TEXT NOT NULL,
|
||||
"tokenTag" TEXT NOT NULL,
|
||||
"tokenLast4" TEXT NOT NULL DEFAULT '',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"webhookRemoteId" TEXT,
|
||||
"webhookSecretCiphertext" TEXT,
|
||||
"webhookSecretIv" TEXT,
|
||||
"webhookSecretTag" TEXT,
|
||||
"customersCursor" TIMESTAMP(3),
|
||||
"syncStatus" TEXT NOT NULL DEFAULT 'idle',
|
||||
"syncStartedAt" TIMESTAMP(3),
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"lastSyncResult" JSONB,
|
||||
"lastHealthAt" TIMESTAMP(3),
|
||||
"lastHealthOk" BOOLEAN,
|
||||
"lastError" TEXT,
|
||||
"lastWebhookAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MontifyConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "MontifyLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"externalUpdatedAt" TIMESTAMP(3),
|
||||
"ordersSyncedAt" TIMESTAMP(3),
|
||||
"data" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MontifyLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "MontifyOrder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"customerExternalId" TEXT,
|
||||
"personId" TEXT,
|
||||
"orderReference" TEXT,
|
||||
"orderStatus" TEXT,
|
||||
"paymentStatus" TEXT,
|
||||
"paymentType" TEXT,
|
||||
"totalPrice" DECIMAL(20,2),
|
||||
"discountTotalPrice" DECIMAL(20,2),
|
||||
"totalDiscounts" DECIMAL(20,2),
|
||||
"totalTax" DECIMAL(20,2),
|
||||
"totalQty" DECIMAL(14,3),
|
||||
"profit" DECIMAL(20,2),
|
||||
"percentProfit" DECIMAL(10,3),
|
||||
"sumGold" DECIMAL(14,3),
|
||||
"sumSilver" DECIMAL(14,3),
|
||||
"address" JSONB,
|
||||
"note" TEXT,
|
||||
"cancelReason" TEXT,
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"closedAt" TIMESTAMP(3),
|
||||
"codeDiscount" TEXT,
|
||||
"items" JSONB NOT NULL DEFAULT '[]',
|
||||
"externalCreatedAt" TIMESTAMP(3),
|
||||
"externalUpdatedAt" TIMESTAMP(3),
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"raw" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MontifyOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "MontifyTransaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"customerExternalId" TEXT,
|
||||
"personId" TEXT,
|
||||
"type" TEXT,
|
||||
"amount" DECIMAL(24,4),
|
||||
"currency" TEXT,
|
||||
"status" TEXT,
|
||||
"referenceCode" TEXT,
|
||||
"externalCreatedAt" TIMESTAMP(3),
|
||||
"raw" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MontifyTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "MontifyWebhookDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"deliveryId" TEXT NOT NULL,
|
||||
"event" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'received',
|
||||
"error" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MontifyWebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "MontifyConnection_organizationId_enabled_idx" ON "MontifyConnection"("organizationId", "enabled");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyLink_connectionId_entityType_externalId_key" ON "MontifyLink"("connectionId", "entityType", "externalId");
|
||||
CREATE INDEX IF NOT EXISTS "MontifyLink_organizationId_entityType_entityId_idx" ON "MontifyLink"("organizationId", "entityType", "entityId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyOrder_connectionId_externalId_key" ON "MontifyOrder"("connectionId", "externalId");
|
||||
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_personId_idx" ON "MontifyOrder"("organizationId", "personId");
|
||||
CREATE INDEX IF NOT EXISTS "MontifyOrder_organizationId_externalCreatedAt_idx" ON "MontifyOrder"("organizationId", "externalCreatedAt");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyTransaction_connectionId_externalId_key" ON "MontifyTransaction"("connectionId", "externalId");
|
||||
CREATE INDEX IF NOT EXISTS "MontifyTransaction_organizationId_personId_idx" ON "MontifyTransaction"("organizationId", "personId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_deliveryId_key" ON "MontifyWebhookDelivery"("connectionId", "deliveryId");
|
||||
CREATE INDEX IF NOT EXISTS "MontifyWebhookDelivery_connectionId_createdAt_idx" ON "MontifyWebhookDelivery"("connectionId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "MontifyConnection" ADD CONSTRAINT "MontifyConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "MontifyLink" ADD CONSTRAINT "MontifyLink_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "MontifyOrder" ADD CONSTRAINT "MontifyOrder_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "MontifyTransaction" ADD CONSTRAINT "MontifyTransaction_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "MontifyWebhookDelivery" ADD CONSTRAINT "MontifyWebhookDelivery_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MontifyConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ماژول جدید برای محیطهایی که فهرست ماژول ذخیرهشده دارند و ووکامرس فعال است
|
||||
UPDATE "Organization"
|
||||
SET "enabledModules" = array_append("enabledModules", 'montify')
|
||||
WHERE cardinality("enabledModules") > 0
|
||||
AND 'woocommerce' = ANY("enabledModules")
|
||||
AND NOT ('montify' = ANY("enabledModules"));
|
||||
@@ -0,0 +1,247 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RoutineTaskStatus" AS ENUM ('todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RoutineRecurrence" AS ENUM ('daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'every_n_days', 'specific_dates');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RoutineTaskSource" AS ENUM ('routine', 'manual', 'follow_up', 'lead', 'customer', 'deal');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoutineCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT '#14b8a6',
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RoutineCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Routine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"assigneeId" TEXT,
|
||||
"teamId" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
|
||||
"timeOfDay" TEXT NOT NULL DEFAULT '09:00',
|
||||
"durationMinutes" INTEGER,
|
||||
"startDate" DATE NOT NULL,
|
||||
"endDate" DATE,
|
||||
"recurrence" "RoutineRecurrence" NOT NULL DEFAULT 'daily',
|
||||
"weekdays" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||
"intervalDays" INTEGER,
|
||||
"monthDay" INTEGER,
|
||||
"specificDates" DATE[],
|
||||
"isMandatory" BOOLEAN NOT NULL DEFAULT true,
|
||||
"allowSkip" BOOLEAN NOT NULL DEFAULT false,
|
||||
"skipReasonRequired" BOOLEAN NOT NULL DEFAULT true,
|
||||
"reportRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||
"allowAfterDeadline" BOOLEAN NOT NULL DEFAULT true,
|
||||
"carryOverOverdue" BOOLEAN NOT NULL DEFAULT false,
|
||||
"entityLink" TEXT NOT NULL DEFAULT 'none',
|
||||
"reminderMinutesBefore" INTEGER,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdById" TEXT,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Routine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoutineTask" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"routineId" TEXT,
|
||||
"assigneeId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"priority" "ActivityPriority" NOT NULL DEFAULT 'medium',
|
||||
"status" "RoutineTaskStatus" NOT NULL DEFAULT 'todo',
|
||||
"scheduledFor" DATE NOT NULL,
|
||||
"dueAt" TIMESTAMP(3),
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"resultNote" TEXT,
|
||||
"skipReason" TEXT,
|
||||
"source" "RoutineTaskSource" NOT NULL DEFAULT 'manual',
|
||||
"isMandatory" BOOLEAN NOT NULL DEFAULT false,
|
||||
"leadId" TEXT,
|
||||
"personId" TEXT,
|
||||
"companyId" TEXT,
|
||||
"dealId" TEXT,
|
||||
"invoiceId" TEXT,
|
||||
"activityId" TEXT,
|
||||
"reminderAt" TIMESTAMP(3),
|
||||
"reminderSentAt" TIMESTAMP(3),
|
||||
"overdueNotifiedAt" TIMESTAMP(3),
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RoutineTask_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoutineTaskEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"taskId" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"type" TEXT NOT NULL,
|
||||
"fromStatus" "RoutineTaskStatus",
|
||||
"toStatus" "RoutineTaskStatus",
|
||||
"note" TEXT,
|
||||
"meta" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RoutineTaskEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoutineUserSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"remindBeforeMinutes" INTEGER DEFAULT 30,
|
||||
"endOfDaySummary" BOOLEAN NOT NULL DEFAULT true,
|
||||
"overdueAlert" BOOLEAN NOT NULL DEFAULT true,
|
||||
"summaryHour" INTEGER NOT NULL DEFAULT 16,
|
||||
"lastSummaryOn" DATE,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RoutineUserSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineCategory_organizationId_sortOrder_idx" ON "RoutineCategory"("organizationId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoutineCategory_organizationId_name_key" ON "RoutineCategory"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Routine_organizationId_isActive_deletedAt_idx" ON "Routine"("organizationId", "isActive", "deletedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Routine_assigneeId_idx" ON "Routine"("assigneeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Routine_teamId_idx" ON "Routine"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_organizationId_assigneeId_scheduledFor_idx" ON "RoutineTask"("organizationId", "assigneeId", "scheduledFor");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_organizationId_status_dueAt_idx" ON "RoutineTask"("organizationId", "status", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_reminderAt_reminderSentAt_idx" ON "RoutineTask"("reminderAt", "reminderSentAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_leadId_idx" ON "RoutineTask"("leadId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_personId_idx" ON "RoutineTask"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_companyId_idx" ON "RoutineTask"("companyId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_dealId_idx" ON "RoutineTask"("dealId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTask_activityId_idx" ON "RoutineTask"("activityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoutineTask_routineId_assigneeId_scheduledFor_key" ON "RoutineTask"("routineId", "assigneeId", "scheduledFor");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTaskEvent_taskId_createdAt_idx" ON "RoutineTaskEvent"("taskId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RoutineTaskEvent_organizationId_createdAt_idx" ON "RoutineTaskEvent"("organizationId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoutineUserSettings_organizationId_userId_key" ON "RoutineUserSettings"("organizationId", "userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineCategory" ADD CONSTRAINT "RoutineCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Routine" ADD CONSTRAINT "Routine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_routineId_fkey" FOREIGN KEY ("routineId") REFERENCES "Routine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "RoutineTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- Enable the new routines module for organizations with an explicit module list
|
||||
UPDATE "Organization"
|
||||
SET "enabledModules" = array_append("enabledModules", 'routines')
|
||||
WHERE cardinality("enabledModules") > 0 AND NOT ('routines' = ANY("enabledModules"));
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Rename the default sales pipeline: «کاریز فروش» → «مسیر فروش»
|
||||
UPDATE "Pipeline" SET "name" = 'مسیر فروش' WHERE "name" = 'کاریز فروش';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActivityStage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"color" TEXT NOT NULL DEFAULT 'green',
|
||||
"kind" "ActivityStatus" NOT NULL DEFAULT 'open',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ActivityStage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "stageId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ActivityStage_organizationId_name_key" ON "ActivityStage"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityStage_organizationId_position_idx" ON "ActivityStage"("organizationId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_stageId_idx" ON "Activity"("stageId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "ActivityStage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "fields" JSONB,
|
||||
ADD COLUMN "participantIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "typeDefId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActivityTypeDef" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"baseType" "ActivityType" NOT NULL DEFAULT 'task',
|
||||
"icon" TEXT NOT NULL DEFAULT 'check',
|
||||
"color" TEXT NOT NULL DEFAULT 'green',
|
||||
"fields" JSONB NOT NULL DEFAULT '[]',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"isSystem" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ActivityTypeDef_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityTypeDef_organizationId_position_idx" ON "ActivityTypeDef"("organizationId", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ActivityTypeDef_organizationId_name_key" ON "ActivityTypeDef"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_typeDefId_idx" ON "Activity"("typeDefId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityTypeDef" ADD CONSTRAINT "ActivityTypeDef_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_typeDefId_fkey" FOREIGN KEY ("typeDefId") REFERENCES "ActivityTypeDef"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,255 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"levels" JSONB NOT NULL DEFAULT '[]',
|
||||
"monthlyGoal" INTEGER NOT NULL DEFAULT 1500,
|
||||
"showFullLeaderboard" BOOLEAN NOT NULL DEFAULT true,
|
||||
"enabledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"event" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"conditions" JSONB NOT NULL DEFAULT '[]',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"maxPerDay" INTEGER,
|
||||
"maxCoinsPerDay" INTEGER,
|
||||
"maxPerUser" INTEGER,
|
||||
"cooldownMinutes" INTEGER,
|
||||
"oncePerEntity" BOOLEAN NOT NULL DEFAULT false,
|
||||
"firstTimeOnly" BOOLEAN NOT NULL DEFAULT false,
|
||||
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
|
||||
"roles" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"teamIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationTransaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"event" TEXT,
|
||||
"ruleId" TEXT,
|
||||
"bonusId" TEXT,
|
||||
"badgeId" TEXT,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'approved',
|
||||
"title" TEXT NOT NULL,
|
||||
"entityType" TEXT,
|
||||
"entityId" TEXT,
|
||||
"entityTitle" TEXT,
|
||||
"idempotencyKey" TEXT,
|
||||
"reason" TEXT,
|
||||
"createdById" TEXT,
|
||||
"decidedById" TEXT,
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GamificationTransaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationWallet" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"coinsEarned" INTEGER NOT NULL DEFAULT 0,
|
||||
"bestMonthScore" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationWallet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationReward" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"cost" INTEGER NOT NULL,
|
||||
"icon" TEXT NOT NULL DEFAULT 'gift',
|
||||
"color" TEXT NOT NULL DEFAULT '#14b8a6',
|
||||
"stock" INTEGER,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"requiresApproval" 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 "GamificationReward_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationRedemption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"rewardId" TEXT NOT NULL,
|
||||
"cost" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"note" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"decidedById" TEXT,
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GamificationRedemption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationBadge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"icon" TEXT NOT NULL DEFAULT 'medal',
|
||||
"color" TEXT NOT NULL DEFAULT '#f59e0b',
|
||||
"metric" TEXT NOT NULL,
|
||||
"event" TEXT,
|
||||
"threshold" INTEGER NOT NULL,
|
||||
"coins" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" 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 "GamificationBadge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationUserBadge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"badgeId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GamificationUserBadge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GamificationBonus" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"kind" TEXT NOT NULL,
|
||||
"event" TEXT,
|
||||
"threshold" INTEGER NOT NULL DEFAULT 1,
|
||||
"period" TEXT NOT NULL DEFAULT 'month',
|
||||
"score" INTEGER NOT NULL DEFAULT 0,
|
||||
"coins" 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 "GamificationBonus_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationSettings_organizationId_key" ON "GamificationSettings"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationRule_organizationId_event_isActive_idx" ON "GamificationRule"("organizationId", "event", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_createdAt_idx" ON "GamificationTransaction"("organizationId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_userId_createdAt_idx" ON "GamificationTransaction"("organizationId", "userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_ruleId_userId_create_idx" ON "GamificationTransaction"("organizationId", "ruleId", "userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationTransaction_organizationId_status_idx" ON "GamificationTransaction"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationTransaction_organizationId_idempotencyKey_key" ON "GamificationTransaction"("organizationId", "idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationWallet_organizationId_score_idx" ON "GamificationWallet"("organizationId", "score");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationWallet_organizationId_userId_key" ON "GamificationWallet"("organizationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationReward_organizationId_isActive_idx" ON "GamificationReward"("organizationId", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationRedemption_organizationId_status_idx" ON "GamificationRedemption"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationRedemption_organizationId_userId_idx" ON "GamificationRedemption"("organizationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationBadge_organizationId_isActive_idx" ON "GamificationBadge"("organizationId", "isActive");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationUserBadge_organizationId_userId_idx" ON "GamificationUserBadge"("organizationId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GamificationUserBadge_badgeId_userId_key" ON "GamificationUserBadge"("badgeId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GamificationBonus_organizationId_isActive_idx" ON "GamificationBonus"("organizationId", "isActive");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationSettings" ADD CONSTRAINT "GamificationSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationRule" ADD CONSTRAINT "GamificationRule_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationTransaction" ADD CONSTRAINT "GamificationTransaction_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationWallet" ADD CONSTRAINT "GamificationWallet_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationReward" ADD CONSTRAINT "GamificationReward_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationRedemption" ADD CONSTRAINT "GamificationRedemption_rewardId_fkey" FOREIGN KEY ("rewardId") REFERENCES "GamificationReward"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationBadge" ADD CONSTRAINT "GamificationBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationUserBadge" ADD CONSTRAINT "GamificationUserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "GamificationBadge"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GamificationBonus" ADD CONSTRAINT "GamificationBonus_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Enable the new module for orgs with an explicit module list
|
||||
UPDATE "Organization"
|
||||
SET "enabledModules" = array_append("enabledModules", 'gamification')
|
||||
WHERE cardinality("enabledModules") > 0 AND NOT ('gamification' = ANY("enabledModules"));
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "leadId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AutomationCursor" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ruleId" TEXT NOT NULL,
|
||||
"stepId" TEXT NOT NULL,
|
||||
"counter" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AutomationCursor_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_leadId_idx" ON "Activity"("leadId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AutomationCursor_ruleId_stepId_key" ON "AutomationCursor"("ruleId", "stepId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AutomationCursor" ADD CONSTRAINT "AutomationCursor_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "AutomationRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Lead" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "Organization" ADD COLUMN "deletionRetention" TEXT NOT NULL DEFAULT 'never';
|
||||
|
||||
CREATE TABLE "DeletionArchive" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"deletedById" TEXT,
|
||||
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DeletionArchive_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "DeletionArchive_organizationId_deletedAt_idx" ON "DeletionArchive"("organizationId", "deletedAt");
|
||||
CREATE INDEX "DeletionArchive_organizationId_model_idx" ON "DeletionArchive"("organizationId", "model");
|
||||
|
||||
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- بایگانی اطلاعات قبل: مرز محیط، مجوز عضو، و منبع فعالیت
|
||||
ALTER TABLE "Organization" ADD COLUMN "priorArchiveEnabledAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "Membership" ADD COLUMN "priorArchiveEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Membership" ADD COLUMN "priorArchiveLens" TEXT NOT NULL DEFAULT 'current';
|
||||
|
||||
ALTER TABLE "Activity" ADD COLUMN "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||
|
||||
CREATE INDEX "Activity_organizationId_importSource_idx" ON "Activity"("organizationId", "importSource");
|
||||
|
||||
-- فعالیت وصل به معاملهٔ دیدار از همین حالا قبلی است. بقیه بعد از روشن شدن ماژول و با تاریخ مرز پر میشوند.
|
||||
UPDATE "Activity" AS a
|
||||
SET "importSource" = 'didar'
|
||||
WHERE a."importSource" = 'manual'
|
||||
AND a."dealId" IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "Deal" AS d
|
||||
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Prior archive: promote tag + cutoff query indexes
|
||||
ALTER TABLE "Deal" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "Activity" ADD COLUMN "promotedFromPriorAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "Deal_organizationId_createdAt_idx" ON "Deal"("organizationId", "createdAt");
|
||||
|
||||
CREATE INDEX "Activity_organizationId_createdAt_idx" ON "Activity"("organizationId", "createdAt");
|
||||
+1974
-426
File diff suppressed because it is too large
Load Diff
+106
-10
@@ -13,7 +13,103 @@ const DEFAULT_STAGES = [
|
||||
{ name: 'از دسترفته', isWon: false, isLost: true },
|
||||
] as const;
|
||||
|
||||
async function seedPlatformAdmin() {
|
||||
const envMobile = (process.env.PLATFORM_ADMIN_MOBILE ?? '').trim();
|
||||
const envEmail = (process.env.PLATFORM_ADMIN_EMAIL ?? '').trim().toLowerCase();
|
||||
const envPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? '';
|
||||
const envName = (process.env.PLATFORM_ADMIN_NAME ?? 'سوپرادمین').trim() || 'سوپرادمین';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const mobileRaw = envMobile || (isProd ? '' : '09120000000');
|
||||
const password = envPassword || (isProd ? '' : 'demo1234');
|
||||
const email = envEmail || null;
|
||||
|
||||
if (!mobileRaw || !password) {
|
||||
console.log(
|
||||
'Platform admin seed skipped (set PLATFORM_ADMIN_MOBILE and PLATFORM_ADMIN_PASSWORD)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
console.log('Platform admin seed skipped (password must be at least 8 chars)');
|
||||
return;
|
||||
}
|
||||
|
||||
// normalize: 09xxxxxxxxx
|
||||
let mobile = mobileRaw.replace(/[\s\-()]/g, '');
|
||||
if (mobile.startsWith('+98')) mobile = `0${mobile.slice(3)}`;
|
||||
if (mobile.startsWith('98') && mobile.length === 12) mobile = `0${mobile.slice(2)}`;
|
||||
if (/^9\d{9}$/.test(mobile)) mobile = `0${mobile}`;
|
||||
if (!/^09\d{9}$/.test(mobile)) {
|
||||
console.log('Platform admin seed skipped (PLATFORM_ADMIN_MOBILE invalid)');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
|
||||
if (existingByMobile) {
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: existingByMobile.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
isActive: true,
|
||||
...(email ? { email } : {}),
|
||||
},
|
||||
});
|
||||
console.log('Platform admin password refreshed:', mobile);
|
||||
return;
|
||||
}
|
||||
if (email) {
|
||||
const existingByEmail = await prisma.platformAdmin.findUnique({ where: { email } });
|
||||
if (existingByEmail) {
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: existingByEmail.id },
|
||||
data: {
|
||||
mobile,
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
console.log('Platform admin linked mobile + password refreshed:', mobile, '→', email);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const orphans = await prisma.platformAdmin.findMany({
|
||||
where: { mobile: null },
|
||||
take: 2,
|
||||
});
|
||||
if (orphans.length === 1) {
|
||||
const orphan = orphans[0]!;
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id: orphan.id },
|
||||
data: {
|
||||
mobile,
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
isActive: true,
|
||||
...(email ? { email } : {}),
|
||||
},
|
||||
});
|
||||
console.log('Platform admin orphan linked mobile:', mobile);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.platformAdmin.create({
|
||||
data: {
|
||||
mobile,
|
||||
email,
|
||||
passwordHash,
|
||||
fullName: envName,
|
||||
},
|
||||
});
|
||||
console.log('Seeded platform admin:', mobile);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await seedPlatformAdmin();
|
||||
|
||||
const email = 'owner@demo.local';
|
||||
let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } });
|
||||
if (!org) {
|
||||
@@ -239,7 +335,7 @@ async function main() {
|
||||
pipeline = await prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
name: 'کاریز فروش',
|
||||
name: 'مسیر فروش',
|
||||
isDefault: true,
|
||||
stages: {
|
||||
create: DEFAULT_STAGES.map((s, index) => ({
|
||||
@@ -258,13 +354,13 @@ async function main() {
|
||||
}
|
||||
|
||||
const servicesPipeline = await prisma.pipeline.findFirst({
|
||||
where: { organizationId: org.id, name: 'کاریز خدمات' },
|
||||
where: { organizationId: org.id, name: 'مسیر خدمات' },
|
||||
});
|
||||
if (!servicesPipeline) {
|
||||
await prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
name: 'کاریز خدمات',
|
||||
name: 'مسیر خدمات',
|
||||
isDefault: false,
|
||||
stages: {
|
||||
create: DEFAULT_STAGES.map((s, index) => ({
|
||||
@@ -281,7 +377,7 @@ async function main() {
|
||||
console.log('Services pipeline already seeded');
|
||||
}
|
||||
|
||||
// کاریز منتظر — مطابق طراحی UI (۳ مرحله + معاملات نمونه)
|
||||
// مسیر منتظر — مطابق طراحی UI (۳ مرحله + معاملات نمونه)
|
||||
const WAITING_STAGES = [
|
||||
{ name: 'پیگیری', isWon: false, isLost: false },
|
||||
{ name: 'مذاکرات اولیه', isWon: false, isLost: false },
|
||||
@@ -387,7 +483,7 @@ async function main() {
|
||||
];
|
||||
|
||||
let waitingPipeline = await prisma.pipeline.findFirst({
|
||||
where: { organizationId: org.id, name: 'کاریز منتظر' },
|
||||
where: { organizationId: org.id, name: 'مسیر منتظر' },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
|
||||
@@ -395,7 +491,7 @@ async function main() {
|
||||
waitingPipeline = await prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
name: 'کاریز منتظر',
|
||||
name: 'مسیر منتظر',
|
||||
isDefault: false,
|
||||
stages: {
|
||||
create: WAITING_STAGES.map((s, index) => ({
|
||||
@@ -408,7 +504,7 @@ async function main() {
|
||||
},
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
console.log('Seeded waiting pipeline (کاریز منتظر)');
|
||||
console.log('Seeded waiting pipeline (مسیر منتظر)');
|
||||
} else {
|
||||
console.log('Waiting pipeline already seeded');
|
||||
}
|
||||
@@ -438,7 +534,7 @@ async function main() {
|
||||
expectedCloseAt: new Date(d.expectedCloseAt),
|
||||
ownerId: owner?.id,
|
||||
position: d.position,
|
||||
tags: ['دمو-کاریز'],
|
||||
tags: ['دمو-مسیر'],
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -667,7 +763,7 @@ async function main() {
|
||||
{
|
||||
organizationId: org.id,
|
||||
authorId: owner?.id,
|
||||
body: 'در دمو نوا روی گزارش کاریز خیلی حساس بودند. نسخه چاپی PDF را آماده کنیم.',
|
||||
body: 'در دمو نوا روی گزارش مسیر فروش خیلی حساس بودند. نسخه چاپی PDF را آماده کنیم.',
|
||||
noteType: 'call_result',
|
||||
color: 'sky',
|
||||
tags: ['دمو'],
|
||||
@@ -875,7 +971,7 @@ async function main() {
|
||||
};
|
||||
|
||||
await ensure('deal_moved', {
|
||||
title: 'جابهجایی معامله در کاریز',
|
||||
title: 'جابهجایی معامله در مسیر فروش',
|
||||
body: sampleDeal
|
||||
? `«${sampleDeal.title}» به مرحله جدید منتقل شد`
|
||||
: 'یک معامله جابهجا شد',
|
||||
|
||||
@@ -1,46 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import {
|
||||
beginExcelImportPrepare,
|
||||
chunkImportMeta,
|
||||
createExcelImportCacheSession,
|
||||
destroyExcelImportSession,
|
||||
getExcelImportPrepareStatus,
|
||||
getExcelImportSessionCaches,
|
||||
resolveExcelImportChunk,
|
||||
type ExcelImportLookupCaches,
|
||||
type ExcelImportSessionStatus,
|
||||
} from '../common/excel-import-session';
|
||||
import type { ImportActivityRowsDto } from './dto';
|
||||
|
||||
const ACTIVITY_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'نوع فعالیت', key: 'kind', width: 16 },
|
||||
{ header: 'عنوان فعالیت', key: 'title', width: 24 },
|
||||
{ header: 'متن فعالیت', key: 'body', width: 36 },
|
||||
{ header: 'نتیجه فعالیت', key: 'result', width: 20 },
|
||||
{ header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 },
|
||||
{ header: 'ثبت کننده فعالیت', key: 'creator', width: 18 },
|
||||
{ header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 },
|
||||
{ header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 },
|
||||
{ header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 },
|
||||
{ header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 },
|
||||
{ header: 'کد معامله فعالیت', key: 'dealCode', width: 14 },
|
||||
{ header: 'کد کارت فعالیت', key: 'cardCode', width: 14 },
|
||||
{ header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 },
|
||||
{ header: 'اشخاص فعالیت', key: 'personName', width: 18 },
|
||||
{ header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 },
|
||||
{ header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 },
|
||||
{ header: 'شرکت های فعالیت', key: 'companyName', width: 20 },
|
||||
{ header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 },
|
||||
{ header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 },
|
||||
{ header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 },
|
||||
{ header: 'هشتگ', key: 'hashtag', width: 16 },
|
||||
];
|
||||
/** ترتیب دقیق خروجی فعالیت دیدار — برای فایلهای بدون ردیف هدر */
|
||||
const DIDAR_ACTIVITY_POSITIONAL = [
|
||||
{ key: '_type', label: '_type' },
|
||||
{ key: 'kind', label: 'نوع فعالیت' },
|
||||
{ key: 'title', label: 'عنوان فعالیت' },
|
||||
{ key: 'body', label: 'متن فعالیت' },
|
||||
{ key: 'result', label: 'نتیجه فعالیت' },
|
||||
{ key: 'assignee', label: 'مسئول اجرا فعالیت' },
|
||||
{ key: 'creator', label: 'ثبت کننده فعالیت' },
|
||||
{ key: 'dueDate', label: 'تاریخ برنامه ریزی شده اجرا فعالیت' },
|
||||
{ key: 'dueTime', label: 'زمان برنامه ریزی شده اجرا فعالیت' },
|
||||
{ key: 'status', label: 'وضعیت اجرای فعالیت' },
|
||||
{ key: 'doneDate', label: 'تاریخ انجام شدن فعالیت' },
|
||||
{ key: 'dealCode', label: 'کد معامله فعالیت' },
|
||||
{ key: 'cardCode', label: 'کد کارت فعالیت' },
|
||||
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
|
||||
{ key: 'personName', label: 'اشخاص فعالیت' },
|
||||
{ key: 'personCode', label: 'کد اشخاص فعالیت' },
|
||||
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
|
||||
{ key: 'companyName', label: 'شرکت های فعالیت' },
|
||||
{ key: 'companyCode', label: 'کد شرکت های فعالیت' },
|
||||
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
|
||||
{ key: 'cardTitle', label: 'کارت فعالیت_1' },
|
||||
{ key: 'hashtag', label: 'هشتگ' },
|
||||
] as const;
|
||||
|
||||
const ACTIVITY_COLUMNS = DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
|
||||
header: c.label,
|
||||
key: c.key,
|
||||
width:
|
||||
c.key === 'body' || c.key === 'result'
|
||||
? 36
|
||||
: c.key === 'title' || c.key === 'dealTitle'
|
||||
? 24
|
||||
: 16,
|
||||
}));
|
||||
|
||||
const ACTIVITY_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
activity: '_type',
|
||||
نوعفعالیت: 'kind',
|
||||
عنوانفعالیت: 'title',
|
||||
عنوان: 'title',
|
||||
@@ -61,10 +90,27 @@ const ACTIVITY_ALIASES: Record<string, string> = {
|
||||
شرکتهایفعالیت: 'companyName',
|
||||
کدشرکتهایفعالیت: 'companyCode',
|
||||
معاملهفعالیت1: 'dealTitle',
|
||||
معاملهفعالیت_1: 'dealTitle',
|
||||
کارتفعالیت1: 'cardTitle',
|
||||
کارتفعالیت_1: 'cardTitle',
|
||||
هشتگ: 'hashtag',
|
||||
};
|
||||
|
||||
function looksLikeDidarActivityHeaderless(firstRow: string[]) {
|
||||
const first = (firstRow[0] ?? '').trim().toLowerCase();
|
||||
if (first !== 'activity' && first !== '_type') return false;
|
||||
const kind = (firstRow[1] ?? '').trim();
|
||||
return /تماس|پیگیری|جلسه|یادداشت|ملاقات|call|task|meeting|note|پیام/i.test(kind);
|
||||
}
|
||||
|
||||
const ACTIVITY_EXCEL_OPTIONS = {
|
||||
positionalColumns: DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
})),
|
||||
detectHeaderless: looksLikeDidarActivityHeaderless,
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<ActivityType, string> = {
|
||||
call: 'تماس با مشتری',
|
||||
meeting: 'جلسه',
|
||||
@@ -105,6 +151,7 @@ export class ActivitiesExcelService {
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
assignee: { select: { fullName: true } },
|
||||
assignees: { select: { fullName: true } },
|
||||
createdBy: { select: { fullName: true } },
|
||||
},
|
||||
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
||||
@@ -120,7 +167,9 @@ export class ActivitiesExcelService {
|
||||
title: row.title,
|
||||
body: row.description ?? '',
|
||||
result: '',
|
||||
assignee: row.assignee?.fullName ?? '',
|
||||
assignee: row.assignees?.length
|
||||
? row.assignees.map((a) => a.fullName).join('، ')
|
||||
: (row.assignee?.fullName ?? ''),
|
||||
creator: row.createdBy?.fullName ?? '',
|
||||
dueDate: formatJalaliDate(row.dueAt),
|
||||
dueTime: formatClock(row.dueAt),
|
||||
@@ -143,13 +192,200 @@ export class ActivitiesExcelService {
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(
|
||||
file,
|
||||
ACTIVITY_ALIASES,
|
||||
excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']),
|
||||
ACTIVITY_EXCEL_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
/**
|
||||
* آپلود فوری + پارس در پسزمینه (جلوگیری از ۵۰۲ nginx روی فایلهای بزرگ).
|
||||
* وضعیت را با prepareStatus پول کنید.
|
||||
*/
|
||||
prepare(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
mappingRaw?: string,
|
||||
): ImportContactsResult {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
const mapping = parseExcelMapping(mappingRaw);
|
||||
const sessionId = beginExcelImportPrepare(
|
||||
actor.organizationId,
|
||||
file.buffer,
|
||||
(f) =>
|
||||
readMappedRows(f, ACTIVITY_ALIASES, mapping, ACTIVITY_EXCEL_OPTIONS),
|
||||
);
|
||||
return {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
sessionId,
|
||||
status: 'parsing',
|
||||
total: 0,
|
||||
parsed: 0,
|
||||
processed: 0,
|
||||
hasMore: true,
|
||||
nextOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
prepareStatus(
|
||||
actor: Actor,
|
||||
sessionId: string,
|
||||
): ExcelImportSessionStatus {
|
||||
return getExcelImportPrepareStatus(sessionId, actor.organizationId);
|
||||
}
|
||||
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File | undefined,
|
||||
mappingRaw?: string,
|
||||
chunk?: { sessionId?: string; offset?: string | number; limit?: string | number },
|
||||
): Promise<ImportContactsResult> {
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await resolveExcelImportChunk({
|
||||
organizationId: actor.organizationId,
|
||||
file,
|
||||
sessionId: chunk?.sessionId,
|
||||
offset: chunk?.offset,
|
||||
limit: chunk?.limit,
|
||||
loadRows: () =>
|
||||
readMappedRows(
|
||||
file!,
|
||||
ACTIVITY_ALIASES,
|
||||
parseExcelMapping(mappingRaw),
|
||||
ACTIVITY_EXCEL_OPTIONS,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof BadRequestException) throw e;
|
||||
const message =
|
||||
e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود';
|
||||
result.errors.push({ row: 1, message });
|
||||
return result;
|
||||
}
|
||||
|
||||
const MAX_ROWS = 100_000;
|
||||
if (ctx.total > MAX_ROWS) {
|
||||
destroyExcelImportSession(ctx.sessionId);
|
||||
result.errors.push({
|
||||
row: 1,
|
||||
message: `فایل بیش از ${MAX_ROWS} ردیف دارد. فایل را تقسیم کنید.`,
|
||||
});
|
||||
return { ...result, hasMore: false, total: ctx.total, processed: 0 };
|
||||
}
|
||||
|
||||
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
|
||||
await this.processActivityRows(
|
||||
actor,
|
||||
ctx.slice,
|
||||
ctx.offset,
|
||||
result,
|
||||
caches,
|
||||
{ skipDuplicateCheck: false },
|
||||
);
|
||||
|
||||
return { ...result, ...chunkImportMeta(ctx) };
|
||||
}
|
||||
|
||||
/**
|
||||
* ایمپورت تکهای JSON از مرورگر — فایل اکسل روی سرور آپلود/پارس نمیشود.
|
||||
*/
|
||||
async importRows(
|
||||
actor: Actor,
|
||||
dto: ImportActivityRowsDto,
|
||||
): Promise<ImportContactsResult> {
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const rows = Array.isArray(dto.rows) ? dto.rows : [];
|
||||
if (!rows.length) {
|
||||
result.errors.push({ row: 1, message: 'ردیف ارسالشده خالی است' });
|
||||
return result;
|
||||
}
|
||||
if (rows.length > 80) {
|
||||
result.errors.push({ row: 1, message: 'حداکثر ۸۰ ردیف در هر درخواست' });
|
||||
return result;
|
||||
}
|
||||
|
||||
const total = Math.max(0, Number(dto.total) || 0);
|
||||
const offset = Math.max(0, Number(dto.offset) || 0);
|
||||
let sessionId = (dto.sessionId ?? '').trim();
|
||||
if (!sessionId) {
|
||||
sessionId = createExcelImportCacheSession(actor.organizationId);
|
||||
}
|
||||
|
||||
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
|
||||
await this.processActivityRows(actor, rows, offset, result, caches, {
|
||||
// برای فایلهای خیلی بزرگ، بررسی تکراری هر ردیف درخواست را طولانی و ۵۰۲ میکند
|
||||
skipDuplicateCheck: total > 2000,
|
||||
});
|
||||
|
||||
const processed = offset + rows.length;
|
||||
const hasMore = total > 0 ? processed < total : false;
|
||||
if (!hasMore) destroyExcelImportSession(sessionId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessionId: hasMore ? sessionId : undefined,
|
||||
total,
|
||||
processed,
|
||||
hasMore,
|
||||
nextOffset: hasMore ? processed : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async processActivityRows(
|
||||
actor: Actor,
|
||||
slice: Array<Record<string, string>>,
|
||||
offset: number,
|
||||
result: ImportContactsResult,
|
||||
caches: ExcelImportLookupCaches,
|
||||
opts: { skipDuplicateCheck: boolean },
|
||||
) {
|
||||
const resolveUser = async (name?: string) => {
|
||||
const key = (name ?? '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
if (caches.user.has(key)) return caches.user.get(key) ?? null;
|
||||
const id = await this.findUserId(actor.organizationId, name);
|
||||
caches.user.set(key, id);
|
||||
return id;
|
||||
};
|
||||
const resolvePerson = async (row: Record<string, string>) => {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const name = (row.personName ?? '').trim().toLowerCase();
|
||||
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||
if (!key) return null;
|
||||
if (caches.person.has(key)) return caches.person.get(key) ?? null;
|
||||
const id = await this.findPersonId(actor.organizationId, row);
|
||||
caches.person.set(key, id);
|
||||
return id;
|
||||
};
|
||||
const resolveCompany = async (name?: string) => {
|
||||
const key = (name ?? '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
if (caches.company.has(key)) return caches.company.get(key) ?? null;
|
||||
const id = await this.findCompanyId(actor.organizationId, name);
|
||||
caches.company.set(key, id);
|
||||
return id;
|
||||
};
|
||||
const resolveDeal = async (title?: string) => {
|
||||
const key = (title ?? '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
if (caches.deal.has(key)) return caches.deal.get(key) ?? null;
|
||||
const id = await this.findDealId(actor.organizationId, title);
|
||||
caches.deal.set(key, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
const row = slice[i]!;
|
||||
const rowNum = offset + i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
|
||||
continue;
|
||||
}
|
||||
@@ -167,31 +403,40 @@ export class ActivitiesExcelService {
|
||||
const status = parseStatus(row.status);
|
||||
|
||||
try {
|
||||
const personId = await this.findPersonId(actor.organizationId, row);
|
||||
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
||||
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
|
||||
const assigneeId =
|
||||
(await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId;
|
||||
const personId = await resolvePerson(row);
|
||||
const companyId = await resolveCompany(row.companyName);
|
||||
const dealId = await resolveDeal(row.dealTitle);
|
||||
const assigneeNames = String(row.assignee ?? '')
|
||||
.split(/[,،]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const assigneeIds: string[] = [];
|
||||
for (const name of assigneeNames) {
|
||||
const uid = await resolveUser(name);
|
||||
if (uid && !assigneeIds.includes(uid)) assigneeIds.push(uid);
|
||||
}
|
||||
if (!assigneeIds.length) assigneeIds.push(actor.userId);
|
||||
const assigneeId = assigneeIds[0]!;
|
||||
const creatorId =
|
||||
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
|
||||
(await resolveUser(row.creator)) ?? actor.userId;
|
||||
|
||||
const duplicate = dueAt
|
||||
? await this.prisma.activity.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
dueAt,
|
||||
personId: personId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
if (!opts.skipDuplicateCheck && dueAt) {
|
||||
const duplicate = await this.prisma.activity.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
dueAt,
|
||||
personId: personId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.activity.create({
|
||||
const created = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
@@ -208,7 +453,20 @@ export class ActivitiesExcelService {
|
||||
assigneeId,
|
||||
createdById: creatorId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (assigneeIds.length > 1) {
|
||||
try {
|
||||
await this.prisma.activity.update({
|
||||
where: { id: created.id },
|
||||
data: {
|
||||
assignees: { set: assigneeIds.map((id) => ({ id })) },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* assigneeId بهتنهایی کافی است */
|
||||
}
|
||||
}
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
result.errors.push({
|
||||
@@ -217,8 +475,6 @@ export class ActivitiesExcelService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -17,28 +18,147 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
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 type { Actor } from '../common/record-scope';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { ActivityStagesService } from './activity-stages.service';
|
||||
import { ActivityTypesService } from './activity-types.service';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
CreateActivityStageDto,
|
||||
CreateActivityTypeDto,
|
||||
ImportActivityRowsDto,
|
||||
ListActivitiesQueryDto,
|
||||
MoveActivityStageDto,
|
||||
ReorderActivityStagesDto,
|
||||
ReorderActivityTypesDto,
|
||||
UpdateActivityDto,
|
||||
UpdateActivityStageDto,
|
||||
UpdateActivityTypeDto,
|
||||
} from './dto';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
const excelUpload = FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
});
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivitiesController {
|
||||
constructor(
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
private readonly excelService: ActivitiesExcelService,
|
||||
private readonly stagesService: ActivityStagesService,
|
||||
private readonly typesService: ActivityTypesService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: Actor) {
|
||||
return this.activitiesService.summary(user.organizationId, user);
|
||||
summary(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('desk') desk?: string,
|
||||
) {
|
||||
const scope = desk === 'mine' || desk === 'team' ? desk : undefined;
|
||||
return this.activitiesService.summary(user.organizationId, user, scope);
|
||||
}
|
||||
|
||||
@Get('stages')
|
||||
listStages(@CurrentUser() user: Actor) {
|
||||
return this.stagesService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('stages')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createStage(@CurrentUser() user: Actor, @Body() dto: CreateActivityStageDto) {
|
||||
return this.stagesService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('stages/reorder')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
reorderStages(@CurrentUser() user: Actor, @Body() dto: ReorderActivityStagesDto) {
|
||||
return this.stagesService.reorder(user.organizationId, dto.stageIds);
|
||||
}
|
||||
|
||||
@Patch('stages/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updateStage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityStageDto,
|
||||
) {
|
||||
return this.stagesService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('stages/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async removeStage(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.stagesService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('types')
|
||||
listTypes(@CurrentUser() user: Actor) {
|
||||
return this.typesService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('types')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createType(@CurrentUser() user: Actor, @Body() dto: CreateActivityTypeDto) {
|
||||
return this.typesService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('types/reorder')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
reorderTypes(@CurrentUser() user: Actor, @Body() dto: ReorderActivityTypesDto) {
|
||||
return this.typesService.reorder(user.organizationId, dto.typeIds);
|
||||
}
|
||||
|
||||
@Patch('types/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updateType(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityTypeDto,
|
||||
) {
|
||||
return this.typesService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('types/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async removeType(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.typesService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('stage-counts')
|
||||
stageCounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query() query: ListActivitiesQueryDto,
|
||||
) {
|
||||
return this.activitiesService.stageCounts(
|
||||
user.organizationId,
|
||||
user,
|
||||
query.filter ?? 'all',
|
||||
query.q,
|
||||
query.desk,
|
||||
{
|
||||
type: query.type,
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -57,11 +177,20 @@ export class ActivitiesController {
|
||||
personId: query.personId,
|
||||
companyId: query.companyId,
|
||||
dealId: query.dealId,
|
||||
leadId: query.leadId,
|
||||
},
|
||||
{
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
},
|
||||
query.desk,
|
||||
{
|
||||
type: query.type,
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,18 +208,62 @@ export class ActivitiesController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(excelUpload)
|
||||
importPreview(@UploadedFile() file?: Express.Multer.File) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
return this.excelService.preview(file);
|
||||
}
|
||||
|
||||
@Post('import/prepare')
|
||||
@UseInterceptors(excelUpload)
|
||||
importPrepare(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
return this.excelService.prepare(user, file, mapping);
|
||||
}
|
||||
|
||||
@Get('import/prepare/:sessionId')
|
||||
importPrepareStatus(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('sessionId') sessionId: string,
|
||||
) {
|
||||
return this.excelService.prepareStatus(user, sessionId);
|
||||
}
|
||||
|
||||
@Post('import/rows')
|
||||
importRows(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: ImportActivityRowsDto,
|
||||
) {
|
||||
return this.excelService.importRows(user, dto);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
@UseInterceptors(excelUpload)
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
@Body('sessionId') sessionId?: string,
|
||||
@Body('offset') offset?: string,
|
||||
@Body('limit') limit?: string,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
if (!file?.buffer?.length && !sessionId) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
return this.excelService.import(user, file, mapping, {
|
||||
sessionId,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -98,6 +271,11 @@ export class ActivitiesController {
|
||||
return this.activitiesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post(':id/promote-from-prior')
|
||||
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.promoteFromPrior(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) {
|
||||
return this.activitiesService.create(
|
||||
@@ -117,6 +295,15 @@ export class ActivitiesController {
|
||||
return this.activitiesService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/stage')
|
||||
moveStage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveActivityStageDto,
|
||||
) {
|
||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
import { ActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { ActivityStagesService } from './activity-stages.service';
|
||||
import { ActivityTypesService } from './activity-types.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
@@ -9,7 +11,12 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [ActivitiesService, ActivitiesExcelService],
|
||||
exports: [ActivitiesService],
|
||||
providers: [
|
||||
ActivitiesService,
|
||||
ActivitiesExcelService,
|
||||
ActivityStagesService,
|
||||
ActivityTypesService,
|
||||
],
|
||||
exports: [ActivitiesService, ActivityStagesService, ActivityTypesService],
|
||||
})
|
||||
export class ActivitiesModule {}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityFieldValues,
|
||||
ActivityFilter,
|
||||
ActivitySummaryDto,
|
||||
ActivityStageCountsDto,
|
||||
CreateActivityRequest,
|
||||
PaginatedResponse,
|
||||
UpdateActivityRequest,
|
||||
@@ -16,9 +19,37 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
|
||||
import {
|
||||
type Actor,
|
||||
activityAccessWhere,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { tehranDayKey } from '../routines/routine-dates';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { activityPriorWhere } from '../common/prior-archive';
|
||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||
import {
|
||||
ActivityTypesService,
|
||||
type SystemTypes,
|
||||
type TypeRow,
|
||||
} from './activity-types.service';
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
|
||||
|
||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||
type StageScope = { stageId: string };
|
||||
|
||||
type ListFacets = {
|
||||
type?: ActivityDto['type'];
|
||||
typeDefId?: string;
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
};
|
||||
|
||||
type ActivityRow = {
|
||||
id: string;
|
||||
@@ -32,21 +63,35 @@ type ActivityRow = {
|
||||
reminderAt: Date | null;
|
||||
reminderNotifyApp: boolean;
|
||||
reminderNotifySms: boolean;
|
||||
reminderSentAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
importSource?: string;
|
||||
promotedFromPriorAt?: Date | null;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
lead?: { id: string; title: string } | null;
|
||||
assignee: { id: string; fullName: string } | null;
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
stage?: { id: string; kind: ActivityDto['status'] } | null;
|
||||
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
||||
fields?: Prisma.JsonValue | null;
|
||||
participantIds?: string[];
|
||||
};
|
||||
|
||||
const include = {
|
||||
stage: { select: { id: true, kind: true } },
|
||||
typeDef: { select: { id: true, name: true, icon: true, color: true } },
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
lead: { select: { id: true, title: true } },
|
||||
assignee: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
|
||||
createdBy: { select: { id: true, fullName: true } },
|
||||
} satisfies Prisma.ActivityInclude;
|
||||
|
||||
@@ -57,21 +102,76 @@ export class ActivitiesService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly stages: ActivityStagesService,
|
||||
private readonly types: ActivityTypesService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||
const [stages, types] = await Promise.all([
|
||||
this.stages.defaults(organizationId),
|
||||
this.types.system(organizationId),
|
||||
]);
|
||||
return { stages, types };
|
||||
}
|
||||
|
||||
async summary(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
desk?: 'mine' | 'team',
|
||||
): Promise<ActivitySummaryDto> {
|
||||
const { start, end } = dayBounds();
|
||||
const scope = assigneeWhere(actor);
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
desk === 'mine'
|
||||
? {
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: activityAccessWhere(actor);
|
||||
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||
const [todayCount, overdueCount] = await Promise.all([
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const [today, overdue, upcoming] = await Promise.all([
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
@@ -79,10 +179,15 @@ export class ActivitiesService {
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }],
|
||||
@@ -90,10 +195,15 @@ export class ActivitiesService {
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
@@ -101,12 +211,13 @@ export class ActivitiesService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const ctx = await this.ctx(organizationId);
|
||||
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,
|
||||
today: today.map((row) => this.toDto(row, ctx)),
|
||||
overdue: overdue.map((row) => this.toDto(row, ctx)),
|
||||
upcoming: upcoming.map((row) => this.toDto(row, ctx)),
|
||||
todayCount,
|
||||
overdueCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,12 +232,17 @@ export class ActivitiesService {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
},
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
): Promise<PaginatedResponse<ActivityDto>> {
|
||||
const typeRows = await this.types.rows(organizationId);
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
actor,
|
||||
@@ -134,7 +250,20 @@ export class ActivitiesService {
|
||||
q,
|
||||
entity,
|
||||
range,
|
||||
desk,
|
||||
facets,
|
||||
stageScope,
|
||||
typeRows,
|
||||
);
|
||||
if (stageScope && stageScope.stageId !== 'all') {
|
||||
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||
await this.stages.whereForStage(organizationId, stageScope.stageId),
|
||||
);
|
||||
}
|
||||
const ctx: DtoContext = {
|
||||
stages: await this.stages.defaults(organizationId),
|
||||
types: this.types.systemOf(typeRows),
|
||||
};
|
||||
const take = Math.min(Math.max(limit, 1), 200);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.activity.findMany({
|
||||
@@ -148,24 +277,107 @@ export class ActivitiesService {
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
items: rows.map((row) => this.toDto(row, ctx)),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
};
|
||||
}
|
||||
|
||||
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
|
||||
async stageCounts(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
filter: ActivityFilter,
|
||||
q?: string,
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: ListFacets,
|
||||
): Promise<ActivityStageCountsDto> {
|
||||
const rows = await this.stages.rows(organizationId);
|
||||
const defaults = this.stages.defaultsOf(rows);
|
||||
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
actor,
|
||||
filter,
|
||||
q,
|
||||
undefined,
|
||||
undefined,
|
||||
desk,
|
||||
facets,
|
||||
{ stageId: 'all' },
|
||||
await this.types.rows(organizationId),
|
||||
);
|
||||
const groups = await this.prisma.activity.groupBy({
|
||||
by: ['status', 'stageId'],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
});
|
||||
const counts: Record<string, number> = Object.fromEntries(rows.map((r) => [r.id, 0]));
|
||||
let total = 0;
|
||||
for (const g of groups) {
|
||||
const n = g._count._all;
|
||||
total += n;
|
||||
const target =
|
||||
g.stageId && kindOf.get(g.stageId) === g.status ? g.stageId : defaults[g.status];
|
||||
if (target) counts[target] = (counts[target] ?? 0) + n;
|
||||
}
|
||||
return { counts, total };
|
||||
}
|
||||
|
||||
async moveToStage(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
stageId: string,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const stage = await this.stages.get(organizationId, stageId);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, completedAt: true },
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId: stage.id,
|
||||
status: stage.kind,
|
||||
completedAt:
|
||||
stage.kind === 'done'
|
||||
? existing?.status === 'done'
|
||||
? existing.completedAt ?? new Date()
|
||||
: new Date()
|
||||
: stage.kind === 'open'
|
||||
? null
|
||||
: undefined,
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityDto> {
|
||||
const row = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||
include,
|
||||
});
|
||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
return this.toDto(row);
|
||||
const dto = this.toDto(row, await this.ctx(organizationId));
|
||||
if (row.participantIds.length) {
|
||||
dto.participantUsers = await this.prisma.user.findMany({
|
||||
where: { id: { in: row.participantIds } },
|
||||
select: { id: true, fullName: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
});
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -176,14 +388,44 @@ export class ActivitiesService {
|
||||
): Promise<ActivityDto> {
|
||||
const linkActor = actor ?? { userId, role: 'owner' };
|
||||
await this.assertLinks(organizationId, linkActor, dto);
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId;
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds?.length
|
||||
? dto.assigneeIds
|
||||
: dto.assigneeId
|
||||
? [dto.assigneeId]
|
||||
: [userId],
|
||||
);
|
||||
const assigneeId = assigneeIds[0] ?? userId;
|
||||
const initialStageId = emptyToNull(dto.stageId ?? null);
|
||||
const initialStage = initialStageId
|
||||
? await this.stages.get(organizationId, initialStageId)
|
||||
: null;
|
||||
const resolved = await this.types.resolve(organizationId, {
|
||||
typeDefId: dto.typeDefId,
|
||||
type: dto.type,
|
||||
});
|
||||
const { fields, participantIds } = await this.types.sanitizeValues(
|
||||
organizationId,
|
||||
resolved.fields,
|
||||
dto.fields,
|
||||
);
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId,
|
||||
...(initialStage
|
||||
? {
|
||||
stageId: initialStage.id,
|
||||
status: initialStage.kind,
|
||||
completedAt: initialStage.kind === 'done' ? new Date() : null,
|
||||
}
|
||||
: {}),
|
||||
title: dto.title.trim(),
|
||||
type: dto.type ?? 'task',
|
||||
type: resolved.baseType,
|
||||
typeDefId: resolved.typeDefId,
|
||||
fields: fields as Prisma.InputJsonValue,
|
||||
participantIds,
|
||||
priority: dto.priority ?? 'medium',
|
||||
description: emptyToNull(dto.description),
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
|
||||
@@ -191,19 +433,23 @@ export class ActivitiesService {
|
||||
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
|
||||
reminderNotifyApp: dto.reminderNotifyApp ?? true,
|
||||
reminderNotifySms: dto.reminderNotifySms ?? false,
|
||||
tags: normalizeTags(dto.tags),
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
assigneeId,
|
||||
assignees: assigneeIds.length
|
||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
createdById: userId,
|
||||
},
|
||||
include,
|
||||
});
|
||||
|
||||
if (assigneeId) {
|
||||
for (const uid of assigneeIds) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: assigneeId,
|
||||
userId: uid,
|
||||
type: 'activity_assigned',
|
||||
title: 'فعالیت جدید برای شما',
|
||||
body: row.title,
|
||||
@@ -212,6 +458,11 @@ export class ActivitiesService {
|
||||
entityId: row.id,
|
||||
});
|
||||
}
|
||||
await this.notifyParticipants(
|
||||
organizationId,
|
||||
row,
|
||||
participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
|
||||
);
|
||||
|
||||
await this.messageAutomations.onEvent('activity_created', {
|
||||
organizationId,
|
||||
@@ -224,8 +475,27 @@ export class ActivitiesService {
|
||||
assigneeId: row.assignee?.id ?? null,
|
||||
ownerId: row.assignee?.id ?? null,
|
||||
});
|
||||
const gameBase = {
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
entityTitle: row.title,
|
||||
facts: {
|
||||
activityType: row.type,
|
||||
typeName: row.typeDef?.name ?? null,
|
||||
priority: row.priority,
|
||||
hasDescription: !!row.description?.trim(),
|
||||
},
|
||||
};
|
||||
this.game.emit({ ...gameBase, event: 'ACTIVITY_CREATED' });
|
||||
if (row.type === 'call') this.game.emit({ ...gameBase, event: 'CALL_LOGGED' });
|
||||
if (row.type === 'meeting') this.game.emit({ ...gameBase, event: 'MEETING_CREATED' });
|
||||
if (initialStage?.kind === 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
|
||||
return this.toDto(row);
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -237,23 +507,71 @@ export class ActivitiesService {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.assertLinks(organizationId, actor, dto);
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
|
||||
});
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
let addedParticipants: string[] = [];
|
||||
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
|
||||
if (typeChanged || dto.fields !== undefined) {
|
||||
const resolved = await this.types.resolve(
|
||||
organizationId,
|
||||
typeChanged
|
||||
? { typeDefId: dto.typeDefId, type: dto.type }
|
||||
: { typeDefId: existing?.typeDefId, type: existing?.type },
|
||||
);
|
||||
const { fields, participantIds } = await this.types.sanitizeValues(
|
||||
organizationId,
|
||||
resolved.fields,
|
||||
dto.fields ?? existing?.fields,
|
||||
);
|
||||
data.type = resolved.baseType;
|
||||
data.typeDefId = resolved.typeDefId;
|
||||
data.fields = fields as Prisma.InputJsonValue;
|
||||
data.participantIds = participantIds;
|
||||
const before = new Set(existing?.participantIds ?? []);
|
||||
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
|
||||
}
|
||||
if (dto.priority !== undefined) data.priority = dto.priority;
|
||||
if ('description' in dto) data.description = emptyToNull(dto.description);
|
||||
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
|
||||
if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? null;
|
||||
if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
|
||||
if ('reminderAt' in dto) {
|
||||
const nextReminder = dto.reminderAt ? new Date(dto.reminderAt) : null;
|
||||
const current = await this.prisma.activity.findUnique({
|
||||
where: { id },
|
||||
select: { reminderAt: true },
|
||||
});
|
||||
const prevMs = current?.reminderAt?.getTime() ?? null;
|
||||
const nextMs = nextReminder?.getTime() ?? null;
|
||||
data.reminderAt = nextReminder;
|
||||
if (prevMs !== nextMs) data.reminderSentAt = null;
|
||||
}
|
||||
if (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp;
|
||||
if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms;
|
||||
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
|
||||
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) {
|
||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
if (!assigneeIds.length) {
|
||||
throw new BadRequestException('حداقل یک مسئول لازم است');
|
||||
}
|
||||
data.assigneeId = assigneeIds[0]!;
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
} else if ('assigneeId' in dto) {
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
data.assigneeId = assigneeId;
|
||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||
else assigneeConnect = [];
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
@@ -261,19 +579,31 @@ export class ActivitiesService {
|
||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
||||
}
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true },
|
||||
});
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data,
|
||||
data: {
|
||||
...data,
|
||||
...(assigneeConnect
|
||||
? { assignees: { set: assigneeConnect } }
|
||||
: {}),
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (addedParticipants.length) {
|
||||
const assigneeSet = new Set([
|
||||
...(row.assignee ? [row.assignee.id] : []),
|
||||
...row.assignees.map((a) => a.id),
|
||||
]);
|
||||
await this.notifyParticipants(
|
||||
organizationId,
|
||||
row,
|
||||
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
|
||||
);
|
||||
}
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row);
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async complete(
|
||||
@@ -294,7 +624,39 @@ export class ActivitiesService {
|
||||
if (existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row);
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
async promoteFromPrior(actor: Actor, id: string): Promise<ActivityDto> {
|
||||
if (
|
||||
(actor.priorLens !== 'current' && actor.priorLens !== 'archive') ||
|
||||
!actor.priorCutoff
|
||||
) {
|
||||
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست.');
|
||||
}
|
||||
|
||||
const row = await this.prisma.activity.findFirst({
|
||||
where: {
|
||||
id,
|
||||
organizationId: actor.organizationId,
|
||||
...activityAccessWhere(actor),
|
||||
},
|
||||
select: { id: true, createdAt: true, promotedFromPriorAt: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
if (row.promotedFromPriorAt) {
|
||||
throw new BadRequestException('این فعالیت قبلاً منتقل شده است.');
|
||||
}
|
||||
if (row.createdAt >= actor.priorCutoff) {
|
||||
throw new BadRequestException('این فعالیت در بایگانی نیست.');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: { promotedFromPriorAt: new Date() },
|
||||
include,
|
||||
});
|
||||
return this.toDto(updated, await this.ctx(actor.organizationId));
|
||||
}
|
||||
|
||||
async remove(
|
||||
@@ -315,36 +677,76 @@ export class ActivitiesService {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
},
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
typeRows: TypeRow[] = [],
|
||||
): 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()
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
desk === 'mine'
|
||||
? {
|
||||
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' } } },
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
: activityAccessWhere(actor);
|
||||
const facetAssignee = facets?.assigneeId?.trim();
|
||||
const facetTypeDef = facets?.typeDefId?.trim();
|
||||
const andParts: Prisma.ActivityWhereInput[] = [
|
||||
scope,
|
||||
...(facets?.type ? [{ type: facets.type }] : []),
|
||||
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
||||
...(facets?.status ? [{ status: facets.status }] : []),
|
||||
...(facetAssignee
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ assigneeId: facetAssignee },
|
||||
{ assignees: { some: { id: facetAssignee } } },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(entity?.personId ? [{ personId: entity.personId }] : []),
|
||||
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
|
||||
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
|
||||
...(entity?.leadId ? [{ leadId: entity.leadId }] : []),
|
||||
];
|
||||
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
|
||||
{ title: ci(t) },
|
||||
{ description: ci(t) },
|
||||
{ tags: { has: t.replace(/^#/, '') } },
|
||||
{
|
||||
person: {
|
||||
OR: [
|
||||
{ firstName: ci(t) },
|
||||
{ lastName: ci(t) },
|
||||
{ mobile: { contains: t } },
|
||||
{ phone: { contains: t } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ company: { name: ci(t) } },
|
||||
{ deal: { title: ci(t) } },
|
||||
{ assignee: { fullName: ci(t) } },
|
||||
{ assignees: { some: { fullName: ci(t) } } },
|
||||
...typesMatching(t).map((type) => ({ type })),
|
||||
...this.types.searchByName(typeRows, t),
|
||||
]);
|
||||
if (search) andParts.push(search);
|
||||
andParts.push(activityPriorWhere(actor.priorLens, actor.priorCutoff));
|
||||
const base: Prisma.ActivityWhereInput = {
|
||||
organizationId,
|
||||
AND: andParts,
|
||||
};
|
||||
|
||||
const fromDate = range?.from ? new Date(range.from) : null;
|
||||
@@ -361,19 +763,33 @@ export class ActivitiesService {
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit status facet overrides the status implied by time tabs.
|
||||
// With stage tabs/columns the stage decides status; time filters only narrow dueAt.
|
||||
const impliedOpen = facets?.status
|
||||
? { status: facets.status }
|
||||
: stageScope
|
||||
? {}
|
||||
: { status: 'open' as const };
|
||||
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
|
||||
|
||||
switch (filter) {
|
||||
case 'today':
|
||||
return { ...base, status: 'open', dueAt: { gte: start, lt: end } };
|
||||
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
|
||||
case 'overdue':
|
||||
return { ...base, status: 'open', dueAt: { lt: start } };
|
||||
// عقبافتاده ذاتاً یعنی باز و موعدگذشته
|
||||
return {
|
||||
...base,
|
||||
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
|
||||
dueAt: { lt: start },
|
||||
};
|
||||
case 'upcoming':
|
||||
return {
|
||||
...base,
|
||||
status: 'open',
|
||||
...impliedOpen,
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
};
|
||||
case 'done':
|
||||
return { ...base, status: 'done' };
|
||||
return { ...base, ...impliedDone };
|
||||
case 'all':
|
||||
default:
|
||||
return base;
|
||||
@@ -389,8 +805,26 @@ export class ActivitiesService {
|
||||
company: { id: string } | null;
|
||||
deal: { id: string } | null;
|
||||
assignee: { id: string } | null;
|
||||
type: string;
|
||||
priority: string;
|
||||
dueAt: Date | null;
|
||||
typeDef?: { name: string } | null;
|
||||
},
|
||||
) {
|
||||
this.game.emit({
|
||||
organizationId,
|
||||
userId: row.assignee?.id,
|
||||
event: 'TASK_COMPLETED',
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
entityTitle: row.title,
|
||||
facts: {
|
||||
activityType: row.type,
|
||||
typeName: row.typeDef?.name ?? null,
|
||||
priority: row.priority,
|
||||
onTime: !row.dueAt || tehranDayKey() <= tehranDayKey(row.dueAt),
|
||||
},
|
||||
});
|
||||
const ctx = {
|
||||
organizationId,
|
||||
entityType: 'activity' as const,
|
||||
@@ -412,7 +846,7 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
) {
|
||||
const found = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...assigneeWhere(actor) },
|
||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
@@ -456,15 +890,44 @@ export class ActivitiesService {
|
||||
}
|
||||
if (dealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId, ...scope },
|
||||
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow): ActivityDto {
|
||||
private async notifyParticipants(
|
||||
organizationId: string,
|
||||
row: { id: string; title: string },
|
||||
userIds: string[],
|
||||
) {
|
||||
for (const uid of new Set(userIds)) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: uid,
|
||||
type: 'activity_assigned',
|
||||
title: 'شما بهعنوان شرکتکننده اضافه شدید',
|
||||
body: row.title,
|
||||
link: `/activities/${row.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: row.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: ActivityRow, ctx?: DtoContext): ActivityDto {
|
||||
const fields =
|
||||
row.fields && typeof row.fields === 'object' && !Array.isArray(row.fields)
|
||||
? (row.fields as unknown as ActivityFieldValues)
|
||||
: {};
|
||||
return {
|
||||
stageId: this.stages.effectiveStageId(row, ctx?.stages),
|
||||
typeDef: row.typeDef
|
||||
? this.types.toSummary(row.typeDef)
|
||||
: (ctx?.types?.[row.type] ?? null),
|
||||
fields,
|
||||
participantIds: row.participantIds ?? [],
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
@@ -476,7 +939,11 @@ export class ActivitiesService {
|
||||
reminderAt: row.reminderAt?.toISOString() ?? null,
|
||||
reminderNotifyApp: row.reminderNotifyApp,
|
||||
reminderNotifySms: row.reminderNotifySms,
|
||||
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
importSource: row.importSource ?? 'manual',
|
||||
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
|
||||
tags: row.tags ?? [],
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
@@ -485,12 +952,53 @@ export class ActivitiesService {
|
||||
: null,
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
lead: row.lead ?? null,
|
||||
assignee: row.assignee,
|
||||
assignees: (() => {
|
||||
const list = [...(row.assignees ?? [])];
|
||||
if (row.assignee && !list.some((a) => a.id === row.assignee!.id)) {
|
||||
list.unshift(row.assignee);
|
||||
}
|
||||
return list;
|
||||
})(),
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async normalizeAssigneeIds(
|
||||
organizationId: string,
|
||||
raw?: string[] | null,
|
||||
): Promise<string[]> {
|
||||
const ids = [
|
||||
...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)),
|
||||
];
|
||||
if (!ids.length) return [];
|
||||
const count = await this.prisma.user.count({
|
||||
where: { organizationId, id: { in: ids }, isActive: true },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_SEARCH_TERMS: Record<ActivityDto['type'], string[]> = {
|
||||
call: ['تماس', 'تلفن', 'call'],
|
||||
meeting: ['جلسه', 'ملاقات', 'meeting'],
|
||||
task: ['وظیفه', 'تسک', 'task'],
|
||||
note: ['پیام', 'یادداشت', 'note'],
|
||||
};
|
||||
|
||||
/** جستجوی «جلسه» یا «تماس» → فیلتر نوع فعالیت */
|
||||
function typesMatching(term: string): ActivityDto['type'][] {
|
||||
const t = term.trim().toLowerCase();
|
||||
if (t.length < 2) return [];
|
||||
return (Object.keys(TYPE_SEARCH_TERMS) as ActivityDto['type'][]).filter((type) =>
|
||||
TYPE_SEARCH_TERMS[type].some((label) => label.includes(t) || t.includes(label)),
|
||||
);
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
ActivityStageColor,
|
||||
ActivityStageDto,
|
||||
ActivityStatus,
|
||||
} from '@mento/shared';
|
||||
import { ACTIVITY_STAGE_COLORS } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [
|
||||
{ name: 'در صف', color: 'orange', kind: 'open' },
|
||||
{ name: 'در حال انجام', color: 'blue', kind: 'open' },
|
||||
{ name: 'انجامشده', color: 'green', kind: 'done' },
|
||||
{ name: 'لغو شده', color: 'rose', kind: 'canceled' },
|
||||
];
|
||||
|
||||
const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
|
||||
|
||||
type StageRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
kind: ActivityStatus;
|
||||
};
|
||||
|
||||
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||
|
||||
@Injectable()
|
||||
export class ActivityStagesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.activityStage.count({ where: { organizationId } });
|
||||
if (count > 0) return;
|
||||
await this.prisma.activityStage.createMany({
|
||||
data: DEFAULT_STAGES.map((s, i) => ({
|
||||
organizationId,
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
kind: s.kind,
|
||||
position: i,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async rows(organizationId: string): Promise<StageRow[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
return this.prisma.activityStage.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
select: { id: true, name: true, position: true, color: true, kind: true },
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<ActivityStageDto[]> {
|
||||
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||
defaultsOf(rows: StageRow[]): StageDefaults {
|
||||
const out: StageDefaults = { open: null, done: null, canceled: null };
|
||||
for (const r of rows) {
|
||||
if (!out[r.kind]) out[r.kind] = r.id;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async defaults(organizationId: string): Promise<StageDefaults> {
|
||||
return this.defaultsOf(await this.rows(organizationId));
|
||||
}
|
||||
|
||||
/** شرط Prisma برای فعالیتهایی که «مؤثراً» در این مرحله هستند */
|
||||
async whereForStage(
|
||||
organizationId: string,
|
||||
stageId: string,
|
||||
): Promise<Prisma.ActivityWhereInput> {
|
||||
const rows = await this.rows(organizationId);
|
||||
const stage = rows.find((r) => r.id === stageId);
|
||||
if (!stage) throw new BadRequestException('مرحلهٔ انتخابشده معتبر نیست');
|
||||
const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id;
|
||||
if (!isDefault) return { status: stage.kind, stageId: stage.id };
|
||||
return {
|
||||
status: stage.kind,
|
||||
OR: [
|
||||
{ stageId: stage.id },
|
||||
{ stageId: null },
|
||||
{ stage: { kind: { not: stage.kind } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
effectiveStageId(
|
||||
row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null },
|
||||
defaults?: StageDefaults,
|
||||
): string | null {
|
||||
if (row.stage && row.stage.kind === row.status) return row.stage.id;
|
||||
return defaults?.[row.status] ?? null;
|
||||
}
|
||||
|
||||
async get(organizationId: string, id: string): Promise<StageRow> {
|
||||
const row = await this.prisma.activityStage.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true, name: true, position: true, color: true, kind: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException('مرحله پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: { name: string; color?: string; kind?: ActivityStatus },
|
||||
): Promise<ActivityStageDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const max = await this.prisma.activityStage.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
const row = await this.prisma.activityStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: this.cleanName(dto.name),
|
||||
color: this.cleanColor(dto.color),
|
||||
kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open',
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string; color?: string; kind?: ActivityStatus },
|
||||
): Promise<ActivityStageDto> {
|
||||
const stage = await this.get(organizationId, id);
|
||||
const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind;
|
||||
if (nextKind !== stage.kind) {
|
||||
await this.assertNotLastOfKind(organizationId, stage);
|
||||
}
|
||||
try {
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
if (nextKind !== stage.kind) {
|
||||
// فعالیتها وضعیتشان را نگه میدارند و به اولین مرحلهٔ همنوع خودشان برمیگردند
|
||||
await tx.activity.updateMany({
|
||||
where: { organizationId, stageId: id },
|
||||
data: { stageId: null },
|
||||
});
|
||||
}
|
||||
return tx.activityStage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
kind: nextKind,
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, stageIds: string[]): Promise<ActivityStageDto[]> {
|
||||
await this.prisma.$transaction(
|
||||
stageIds.map((id, position) =>
|
||||
this.prisma.activityStage.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: { position },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const stage = await this.get(organizationId, id);
|
||||
await this.assertNotLastOfKind(organizationId, stage);
|
||||
// onDelete: SetNull → فعالیتها به اولین مرحلهٔ همنوع وضعیتشان منتقل میشوند
|
||||
await this.prisma.activityStage.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async assertNotLastOfKind(organizationId: string, stage: StageRow) {
|
||||
const sameKind = await this.prisma.activityStage.count({
|
||||
where: { organizationId, kind: stage.kind },
|
||||
});
|
||||
if (sameKind <= 1) {
|
||||
const label =
|
||||
stage.kind === 'done' ? 'انجامشده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز';
|
||||
throw new BadRequestException(
|
||||
`حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanName(raw: string): string {
|
||||
const name = raw?.trim() ?? '';
|
||||
if (!name) throw new BadRequestException('نام ستون لازم است');
|
||||
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private cleanColor(raw?: string): ActivityStageColor {
|
||||
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||
? (raw as ActivityStageColor)
|
||||
: 'green';
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('ستونی با این نام وجود دارد');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
private toDto(row: StageRow): ActivityStageDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
color: this.cleanColor(row.color),
|
||||
kind: row.kind,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityFieldDef,
|
||||
ActivityFieldType,
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
ActivityTypeIcon,
|
||||
ActivityTypeSummaryDto,
|
||||
CreateActivityTypeRequest,
|
||||
UpdateActivityTypeRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
ACTIVITY_FIELD_TYPES,
|
||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
const MAX_FIELDS = 30;
|
||||
const MAX_OPTIONS = 50;
|
||||
const MAX_CHECKLIST = 100;
|
||||
const MAX_PARTICIPANTS = 100;
|
||||
|
||||
type Seed = {
|
||||
name: string;
|
||||
baseType: ActivityType;
|
||||
icon: ActivityTypeIcon;
|
||||
color: ActivityStageColor;
|
||||
isSystem?: boolean;
|
||||
fields: ActivityFieldDef[];
|
||||
};
|
||||
|
||||
const DEFAULT_TYPES: Seed[] = [
|
||||
{
|
||||
name: 'تماس',
|
||||
baseType: 'call',
|
||||
icon: 'phone',
|
||||
color: 'green',
|
||||
isSystem: true,
|
||||
fields: [
|
||||
{ key: 'call_direction', label: 'جهت تماس', type: 'select', options: ['خروجی', 'ورودی'] },
|
||||
{
|
||||
key: 'call_result',
|
||||
label: 'نتیجه تماس',
|
||||
type: 'select',
|
||||
options: [
|
||||
'پاسخ داد',
|
||||
'پاسخ نداد',
|
||||
'مشغول بود',
|
||||
'خاموش / در دسترس نبود',
|
||||
'شماره اشتباه',
|
||||
'نیاز به پیگیری مجدد',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'جلسه',
|
||||
baseType: 'meeting',
|
||||
icon: 'users',
|
||||
color: 'purple',
|
||||
isSystem: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'meeting_subject',
|
||||
label: 'موضوع جلسه',
|
||||
type: 'text',
|
||||
placeholder: 'مثلاً: بررسی پیشفاکتور و شرایط پرداخت',
|
||||
},
|
||||
{ key: 'meeting_participants', label: 'شرکتکنندگان', type: 'participants' },
|
||||
{
|
||||
key: 'meeting_location',
|
||||
label: 'محل برگزاری / لینک جلسه',
|
||||
type: 'text',
|
||||
placeholder: 'آدرس یا لینک جلسهٔ آنلاین',
|
||||
},
|
||||
{ key: 'meeting_minutes', label: 'صورتجلسه', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'وظیفه',
|
||||
baseType: 'task',
|
||||
icon: 'check',
|
||||
color: 'orange',
|
||||
isSystem: true,
|
||||
fields: [{ key: 'task_checklist', label: 'لیست کارها', type: 'checklist' }],
|
||||
},
|
||||
{
|
||||
name: 'پیام',
|
||||
baseType: 'note',
|
||||
icon: 'chat',
|
||||
color: 'blue',
|
||||
isSystem: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'message_channel',
|
||||
label: 'کانال ارسال',
|
||||
type: 'select',
|
||||
options: ['پیامک', 'واتساپ', 'تلگرام', 'ایتا', 'بله', 'ایمیل', 'دایرکت اینستاگرام'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'بازدید حضوری',
|
||||
baseType: 'meeting',
|
||||
icon: 'pin',
|
||||
color: 'rose',
|
||||
fields: [
|
||||
{ key: 'visit_address', label: 'آدرس محل بازدید', type: 'text' },
|
||||
{ key: 'visit_participants', label: 'همراهان بازدید', type: 'participants' },
|
||||
{ key: 'visit_result', label: 'نتیجهٔ بازدید', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ارائه / دمو محصول',
|
||||
baseType: 'meeting',
|
||||
icon: 'star',
|
||||
color: 'purple',
|
||||
fields: [
|
||||
{ key: 'demo_product', label: 'محصول / خدمت', type: 'text' },
|
||||
{ key: 'demo_participants', label: 'شرکتکنندگان', type: 'participants' },
|
||||
{ key: 'demo_link', label: 'لینک جلسه', type: 'url' },
|
||||
{
|
||||
key: 'demo_result',
|
||||
label: 'نتیجهٔ دمو',
|
||||
type: 'select',
|
||||
options: ['علاقهمند', 'نیاز به بررسی بیشتر', 'درخواست پیشفاکتور', 'عدم علاقه'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'پیگیری',
|
||||
baseType: 'call',
|
||||
icon: 'bell',
|
||||
color: 'orange',
|
||||
fields: [
|
||||
{
|
||||
key: 'followup_reason',
|
||||
label: 'دلیل پیگیری',
|
||||
type: 'select',
|
||||
options: ['پیشفاکتور', 'پرداخت', 'ارسال کالا', 'پشتیبانی', 'تمدید قرارداد', 'سایر'],
|
||||
},
|
||||
{ key: 'followup_result', label: 'نتیجهٔ پیگیری', type: 'textarea' },
|
||||
{ key: 'followup_next', label: 'تاریخ پیگیری بعدی', type: 'date' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ایمیل',
|
||||
baseType: 'note',
|
||||
icon: 'mail',
|
||||
color: 'slate',
|
||||
fields: [
|
||||
{ key: 'email_subject', label: 'موضوع ایمیل', type: 'text' },
|
||||
{ key: 'email_to', label: 'گیرنده', type: 'text', placeholder: 'example@domain.com' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export type TypeRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseType: ActivityType;
|
||||
icon: string;
|
||||
color: string;
|
||||
fields: Prisma.JsonValue;
|
||||
position: number;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
};
|
||||
|
||||
/** نوع اصلی هر baseType — مقصد فعالیتهای بدون typeDefId */
|
||||
export type SystemTypes = Record<ActivityType, ActivityTypeSummaryDto | null>;
|
||||
|
||||
export type ResolvedType = {
|
||||
/** مقداری که در Activity.typeDefId ذخیره میشود (نوع اصلی → null) */
|
||||
typeDefId: string | null;
|
||||
baseType: ActivityType;
|
||||
fields: ActivityFieldDef[];
|
||||
};
|
||||
|
||||
const select = {
|
||||
id: true,
|
||||
name: true,
|
||||
baseType: true,
|
||||
icon: true,
|
||||
color: true,
|
||||
fields: true,
|
||||
position: true,
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
} satisfies Prisma.ActivityTypeDefSelect;
|
||||
|
||||
@Injectable()
|
||||
export class ActivityTypesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.activityTypeDef.count({ where: { organizationId } });
|
||||
if (count > 0) return;
|
||||
await this.prisma.activityTypeDef.createMany({
|
||||
data: DEFAULT_TYPES.map((t, i) => ({
|
||||
organizationId,
|
||||
name: t.name,
|
||||
baseType: t.baseType,
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
isSystem: Boolean(t.isSystem),
|
||||
fields: t.fields as unknown as Prisma.InputJsonValue,
|
||||
position: i,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async rows(organizationId: string): Promise<TypeRow[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
return this.prisma.activityTypeDef.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<ActivityTypeDefDto[]> {
|
||||
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
systemOf(rows: TypeRow[]): SystemTypes {
|
||||
const out: SystemTypes = { call: null, meeting: null, task: null, note: null };
|
||||
for (const r of rows) {
|
||||
if (r.isSystem && !out[r.baseType]) out[r.baseType] = this.toSummary(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async system(organizationId: string): Promise<SystemTypes> {
|
||||
return this.systemOf(await this.rows(organizationId));
|
||||
}
|
||||
|
||||
/** فیلتر Prisma برای فعالیتهایی که «مؤثراً» از این نوع هستند */
|
||||
whereForType(rows: TypeRow[], typeDefId: string): Prisma.ActivityWhereInput {
|
||||
const def = rows.find((r) => r.id === typeDefId);
|
||||
if (!def) return { id: '__none__' };
|
||||
if (!def.isSystem) return { typeDefId: def.id };
|
||||
return { OR: [{ typeDefId: def.id }, { typeDefId: null, type: def.baseType }] };
|
||||
}
|
||||
|
||||
/** جستجوی نام نوع → شرطهای OR */
|
||||
searchByName(rows: TypeRow[], term: string): Prisma.ActivityWhereInput[] {
|
||||
const t = term.trim();
|
||||
if (t.length < 2) return [];
|
||||
return rows
|
||||
.filter((r) => r.name.includes(t))
|
||||
.map((r) => this.whereForType(rows, r.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* نوع مؤثر برای ذخیره: typeDefId صریح، یا نوع اصلیِ type.
|
||||
* نوع اصلی با typeDefId=null ذخیره میشود تا با دادههای قدیمی یکسان باشد.
|
||||
*/
|
||||
async resolve(
|
||||
organizationId: string,
|
||||
input: { typeDefId?: string | null; type?: ActivityType | null },
|
||||
): Promise<ResolvedType> {
|
||||
const rows = await this.rows(organizationId);
|
||||
const requested = input.typeDefId?.trim();
|
||||
if (requested) {
|
||||
const def = rows.find((r) => r.id === requested);
|
||||
if (!def) throw new BadRequestException('نوع فعالیت انتخابشده معتبر نیست');
|
||||
return {
|
||||
typeDefId: def.isSystem ? null : def.id,
|
||||
baseType: def.baseType,
|
||||
fields: this.parseFields(def.fields),
|
||||
};
|
||||
}
|
||||
const baseType = input.type && BASE_TYPES.includes(input.type) ? input.type : 'task';
|
||||
const sys = rows.find((r) => r.isSystem && r.baseType === baseType);
|
||||
return { typeDefId: null, baseType, fields: sys ? this.parseFields(sys.fields) : [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* پاکسازی مقادیر بر اساس تعریف فیلدها؛ فقط کلیدهای همین نوع نگه داشته میشوند.
|
||||
* participantIds = کاربران معتبر همهٔ فیلدهای شرکتکنندگان.
|
||||
*/
|
||||
async sanitizeValues(
|
||||
organizationId: string,
|
||||
defs: ActivityFieldDef[],
|
||||
raw: unknown,
|
||||
): Promise<{ fields: ActivityFieldValues; participantIds: string[] }> {
|
||||
const input =
|
||||
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
||||
const out: ActivityFieldValues = {};
|
||||
const participantFields: Array<{ key: string; value: ActivityParticipantsValue }> = [];
|
||||
|
||||
for (const def of defs) {
|
||||
const value = this.cleanValue(def, input[def.key]);
|
||||
if (value === null) {
|
||||
if (def.required && def.type !== 'checkbox') {
|
||||
throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (def.type === 'participants') {
|
||||
participantFields.push({ key: def.key, value: value as ActivityParticipantsValue });
|
||||
}
|
||||
out[def.key] = value;
|
||||
}
|
||||
|
||||
const userIds = [...new Set(participantFields.flatMap((p) => p.value.userIds))];
|
||||
const personIds = [...new Set(participantFields.flatMap((p) => p.value.people.map((x) => x.id)))];
|
||||
const [validUsers, validPeople] = await Promise.all([
|
||||
userIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: userIds }, memberships: { some: { organizationId } } },
|
||||
select: { id: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
personIds.length
|
||||
? this.prisma.person.findMany({
|
||||
where: { id: { in: personIds }, organizationId },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const userSet = new Set(validUsers.map((u) => u.id));
|
||||
const personNames = new Map(
|
||||
validPeople.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]),
|
||||
);
|
||||
for (const p of participantFields) {
|
||||
const next: ActivityParticipantsValue = {
|
||||
userIds: p.value.userIds.filter((id) => userSet.has(id)),
|
||||
people: p.value.people
|
||||
.filter((x) => personNames.has(x.id))
|
||||
.map((x) => ({ id: x.id, name: personNames.get(x.id) || x.name })),
|
||||
guests: p.value.guests,
|
||||
};
|
||||
const def = defs.find((d) => d.key === p.key)!;
|
||||
if (!next.userIds.length && !next.people.length && !next.guests.length) {
|
||||
if (def.required) throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
|
||||
delete out[p.key];
|
||||
} else {
|
||||
out[p.key] = next;
|
||||
}
|
||||
}
|
||||
|
||||
const participantIds = [
|
||||
...new Set(
|
||||
Object.values(out)
|
||||
.filter((v): v is ActivityParticipantsValue => isParticipants(v))
|
||||
.flatMap((v) => v.userIds),
|
||||
),
|
||||
];
|
||||
return { fields: out, participantIds };
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateActivityTypeRequest,
|
||||
): Promise<ActivityTypeDefDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const max = await this.prisma.activityTypeDef.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
const row = await this.prisma.activityTypeDef.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: this.cleanName(dto.name),
|
||||
baseType: this.cleanBase(dto.baseType),
|
||||
icon: this.cleanIcon(dto.icon),
|
||||
color: this.cleanColor(dto.color),
|
||||
fields: this.cleanFieldDefs(dto.fields ?? []) as unknown as Prisma.InputJsonValue,
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
select,
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateActivityTypeRequest,
|
||||
): Promise<ActivityTypeDefDto> {
|
||||
const current = await this.get(organizationId, id);
|
||||
if (current.isSystem && dto.baseType && dto.baseType !== current.baseType) {
|
||||
throw new BadRequestException('نوع پایهٔ انواع اصلی قابل تغییر نیست');
|
||||
}
|
||||
if (current.isSystem && dto.isActive === false) {
|
||||
throw new BadRequestException('انواع اصلی را نمیتوان غیرفعال کرد');
|
||||
}
|
||||
const nextBase = dto.baseType ? this.cleanBase(dto.baseType) : current.baseType;
|
||||
try {
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
if (nextBase !== current.baseType) {
|
||||
await tx.activity.updateMany({
|
||||
where: { organizationId, typeDefId: id },
|
||||
data: { type: nextBase },
|
||||
});
|
||||
}
|
||||
return tx.activityTypeDef.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.icon != null ? { icon: this.cleanIcon(dto.icon) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
...(dto.fields != null
|
||||
? { fields: this.cleanFieldDefs(dto.fields) as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(dto.isActive != null ? { isActive: dto.isActive } : {}),
|
||||
baseType: nextBase,
|
||||
},
|
||||
select,
|
||||
});
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, typeIds: string[]): Promise<ActivityTypeDefDto[]> {
|
||||
await this.prisma.$transaction(
|
||||
typeIds.map((id, position) =>
|
||||
this.prisma.activityTypeDef.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: { position },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const def = await this.get(organizationId, id);
|
||||
if (def.isSystem) throw new BadRequestException('انواع اصلی قابل حذف نیستند');
|
||||
// onDelete: SetNull → فعالیتها به نوع اصلیِ نوع پایهٔ خود برمیگردند
|
||||
await this.prisma.activityTypeDef.delete({ where: { id } });
|
||||
}
|
||||
|
||||
toSummary(row: Pick<TypeRow, 'id' | 'name' | 'icon' | 'color'>): ActivityTypeSummaryDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
icon: this.cleanIcon(row.icon),
|
||||
color: this.cleanColor(row.color),
|
||||
};
|
||||
}
|
||||
|
||||
parseFields(raw: Prisma.JsonValue): ActivityFieldDef[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(f): f is ActivityFieldDef & Prisma.JsonObject =>
|
||||
Boolean(f) && typeof f === 'object' && !Array.isArray(f) && typeof (f as { key?: unknown }).key === 'string',
|
||||
) as ActivityFieldDef[];
|
||||
}
|
||||
|
||||
private async get(organizationId: string, id: string): Promise<TypeRow> {
|
||||
const row = await this.prisma.activityTypeDef.findFirst({
|
||||
where: { id, organizationId },
|
||||
select,
|
||||
});
|
||||
if (!row) throw new NotFoundException('نوع فعالیت پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private cleanValue(def: ActivityFieldDef, raw: unknown): ActivityFieldValue {
|
||||
switch (def.type) {
|
||||
case 'text':
|
||||
return cleanText(raw, 500);
|
||||
case 'textarea':
|
||||
return cleanText(raw, 5000);
|
||||
case 'url':
|
||||
return cleanText(raw, 1000);
|
||||
case 'number': {
|
||||
if (raw === '' || raw == null) return null;
|
||||
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
case 'date': {
|
||||
const s = cleanText(raw, 40);
|
||||
return s && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(new Date(s).getTime())
|
||||
? s
|
||||
: null;
|
||||
}
|
||||
case 'select': {
|
||||
const s = cleanText(raw, 200);
|
||||
return s && (def.options ?? []).includes(s) ? s : null;
|
||||
}
|
||||
case 'multiselect': {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const opts = new Set(def.options ?? []);
|
||||
const list = [...new Set(raw.filter((v): v is string => typeof v === 'string' && opts.has(v)))];
|
||||
return list.length ? list : null;
|
||||
}
|
||||
case 'checkbox':
|
||||
return raw === true ? true : raw === false ? false : null;
|
||||
case 'checklist': {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const items: ActivityChecklistItem[] = [];
|
||||
for (const it of raw.slice(0, MAX_CHECKLIST)) {
|
||||
if (!it || typeof it !== 'object') continue;
|
||||
const o = it as Record<string, unknown>;
|
||||
const text = cleanText(o.text, 300);
|
||||
if (!text) continue;
|
||||
const id = typeof o.id === 'string' && o.id.trim() ? o.id.trim().slice(0, 64) : randomUUID();
|
||||
items.push({ id, text, done: o.done === true });
|
||||
}
|
||||
return items.length ? items : null;
|
||||
}
|
||||
case 'participants': {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const userIds = Array.isArray(o.userIds)
|
||||
? [...new Set(o.userIds.filter((v): v is string => typeof v === 'string' && Boolean(v.trim())))]
|
||||
: [];
|
||||
const people = Array.isArray(o.people)
|
||||
? o.people
|
||||
.filter(
|
||||
(p): p is { id: string; name?: unknown } =>
|
||||
Boolean(p) && typeof p === 'object' && typeof (p as { id?: unknown }).id === 'string',
|
||||
)
|
||||
.map((p) => ({ id: p.id, name: cleanText(p.name, 200) ?? '' }))
|
||||
: [];
|
||||
const guests = Array.isArray(o.guests)
|
||||
? [
|
||||
...new Set(
|
||||
o.guests
|
||||
.map((g) => cleanText(g, 120))
|
||||
.filter((g): g is string => Boolean(g)),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
if (!userIds.length && !people.length && !guests.length) return null;
|
||||
return {
|
||||
userIds: userIds.slice(0, MAX_PARTICIPANTS),
|
||||
people: people.slice(0, MAX_PARTICIPANTS),
|
||||
guests: guests.slice(0, MAX_PARTICIPANTS),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanFieldDefs(raw: ActivityFieldDef[]): ActivityFieldDef[] {
|
||||
if (!Array.isArray(raw)) throw new BadRequestException('تعریف فیلدها نامعتبر است');
|
||||
if (raw.length > MAX_FIELDS) {
|
||||
throw new BadRequestException(`حداکثر ${MAX_FIELDS} فیلد برای هر نوع مجاز است`);
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
return raw.map((f, i) => {
|
||||
const label = typeof f?.label === 'string' ? f.label.trim() : '';
|
||||
if (!label) throw new BadRequestException(`عنوان فیلد ${i + 1} لازم است`);
|
||||
if (label.length > 80) throw new BadRequestException('عنوان فیلد حداکثر ۸۰ کاراکتر است');
|
||||
const type = ACTIVITY_FIELD_TYPES.includes(f.type as ActivityFieldType)
|
||||
? (f.type as ActivityFieldType)
|
||||
: null;
|
||||
if (!type) throw new BadRequestException(`نوع فیلد «${label}» معتبر نیست`);
|
||||
let key = typeof f.key === 'string' ? f.key.trim().toLowerCase() : '';
|
||||
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) key = `f_${randomUUID().slice(0, 8)}`;
|
||||
if (keys.has(key)) throw new BadRequestException(`کلید فیلد «${label}» تکراری است`);
|
||||
keys.add(key);
|
||||
const def: ActivityFieldDef = { key, label, type };
|
||||
if (ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type)) {
|
||||
const options = [
|
||||
...new Set(
|
||||
(Array.isArray(f.options) ? f.options : [])
|
||||
.map((o) => (typeof o === 'string' ? o.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.map((o) => o.slice(0, 200)),
|
||||
),
|
||||
].slice(0, MAX_OPTIONS);
|
||||
if (!options.length) {
|
||||
throw new BadRequestException(`برای فیلد «${label}» حداقل یک گزینه تعریف کنید`);
|
||||
}
|
||||
def.options = options;
|
||||
}
|
||||
if (f.required) def.required = true;
|
||||
const placeholder = typeof f.placeholder === 'string' ? f.placeholder.trim().slice(0, 200) : '';
|
||||
if (placeholder) def.placeholder = placeholder;
|
||||
return def;
|
||||
});
|
||||
}
|
||||
|
||||
private cleanName(raw: string): string {
|
||||
const name = raw?.trim() ?? '';
|
||||
if (!name) throw new BadRequestException('نام نوع فعالیت لازم است');
|
||||
if (name.length > 60) throw new BadRequestException('نام نوع فعالیت حداکثر ۶۰ کاراکتر است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private cleanBase(raw?: ActivityType): ActivityType {
|
||||
return raw && BASE_TYPES.includes(raw) ? raw : 'task';
|
||||
}
|
||||
|
||||
private cleanIcon(raw?: string): ActivityTypeIcon {
|
||||
return (ACTIVITY_TYPE_ICONS as readonly string[]).includes(raw ?? '')
|
||||
? (raw as ActivityTypeIcon)
|
||||
: 'check';
|
||||
}
|
||||
|
||||
private cleanColor(raw?: string): ActivityStageColor {
|
||||
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
|
||||
? (raw as ActivityStageColor)
|
||||
: 'green';
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
private toDto(row: TypeRow): ActivityTypeDefDto {
|
||||
return {
|
||||
...this.toSummary(row),
|
||||
baseType: row.baseType,
|
||||
fields: this.parseFields(row.fields),
|
||||
position: row.position,
|
||||
isActive: row.isActive,
|
||||
isSystem: row.isSystem,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function cleanText(raw: unknown, max: number): string | null {
|
||||
if (typeof raw !== 'string' && typeof raw !== 'number') return null;
|
||||
const s = String(raw).trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
}
|
||||
|
||||
function isParticipants(v: unknown): v is ActivityParticipantsValue {
|
||||
return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Array.isArray((v as ActivityParticipantsValue).userIds);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
Allow,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
@@ -12,6 +15,29 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
||||
|
||||
/** تکههای JSON از ایمپورت مرورگرگرا */
|
||||
export class ImportActivityRowsDto {
|
||||
@IsArray()
|
||||
@Allow()
|
||||
rows!: Array<Record<string, string>>;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
total!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
|
||||
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
|
||||
@@ -27,6 +53,32 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['mine', 'team'])
|
||||
desk?: 'mine' | 'team';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
typeDefId?: string;
|
||||
|
||||
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
@@ -39,6 +91,10 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
dealId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leadId?: string;
|
||||
|
||||
/** Inclusive start (ISO). When set with/without filter, restricts dueAt. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@@ -63,16 +119,134 @@ export class ListActivitiesQueryDto {
|
||||
limit?: number = 50;
|
||||
}
|
||||
|
||||
const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const;
|
||||
|
||||
export class CreateActivityStageDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
kind?: (typeof ACTIVITY_STATUSES)[number];
|
||||
}
|
||||
|
||||
export class UpdateActivityStageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
kind?: (typeof ACTIVITY_STATUSES)[number];
|
||||
}
|
||||
|
||||
export class ReorderActivityStagesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
stageIds!: string[];
|
||||
}
|
||||
|
||||
export class MoveActivityStageDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
}
|
||||
|
||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||
export class CreateActivityTypeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
baseType?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPE_ICONS)
|
||||
icon?: ActivityTypeIcon;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Allow()
|
||||
fields?: ActivityFieldDef[];
|
||||
}
|
||||
|
||||
export class UpdateActivityTypeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
baseType?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPE_ICONS)
|
||||
icon?: ActivityTypeIcon;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(STAGE_COLORS)
|
||||
color?: (typeof STAGE_COLORS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Allow()
|
||||
fields?: ActivityFieldDef[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ReorderActivityTypesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
typeIds!: string[];
|
||||
}
|
||||
|
||||
export class CreateActivityDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
stageId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
typeDefId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
fields?: ActivityFieldValues;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_PRIORITIES)
|
||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||
@@ -107,6 +281,11 @@ export class CreateActivityDto {
|
||||
@IsBoolean()
|
||||
reminderNotifySms?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -126,6 +305,11 @@ export class CreateActivityDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@@ -139,6 +323,15 @@ export class UpdateActivityDto {
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
typeDefId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
fields?: ActivityFieldValues;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||
@@ -177,6 +370,11 @@ export class UpdateActivityDto {
|
||||
@IsBoolean()
|
||||
reminderNotifySms?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -196,4 +394,9 @@ export class UpdateActivityDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
+1033
-24
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class AiSttService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return (
|
||||
this.config.get<string>('STT_ENABLED') === 'true' &&
|
||||
Boolean(this.sttUrl())
|
||||
);
|
||||
}
|
||||
|
||||
async transcribe(file: Express.Multer.File | undefined) {
|
||||
if (!this.isEnabled()) {
|
||||
throw new ServiceUnavailableException('تبدیل صوت به متن فعال نیست');
|
||||
}
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل صوتی ارسال نشده است');
|
||||
}
|
||||
if (file.size > MAX_AUDIO_BYTES) {
|
||||
throw new BadRequestException('حجم فایل صوتی بیش از حد مجاز است');
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'audio',
|
||||
new Blob([new Uint8Array(file.buffer)], {
|
||||
type: file.mimetype || 'audio/wav',
|
||||
}),
|
||||
file.originalname || 'audio.wav',
|
||||
);
|
||||
|
||||
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 60000);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
|
||||
if (token) headers['X-STT-Token'] = token;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${this.sttUrl()}/transcribe`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'سرویس تبدیل صوت در دسترس نیست. Pipecat STT را اجرا کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
text?: string;
|
||||
detail?: string | Array<{ msg?: string }>;
|
||||
};
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = Array.isArray(payload.detail)
|
||||
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
|
||||
: payload.detail;
|
||||
throw new ServiceUnavailableException(
|
||||
detail || 'تبدیل صوت به متن ناموفق بود',
|
||||
);
|
||||
}
|
||||
|
||||
return { text: (payload.text ?? '').trim() };
|
||||
}
|
||||
|
||||
private sttUrl(): string {
|
||||
return (this.config.get<string>('STT_URL') ?? '').trim().replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { AiAskService } from './ai-ask.service';
|
||||
import { AskMentoDto } from './dto';
|
||||
import { AskMentoDto, VoiceCommandDto } from './dto';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiSttService } from './ai-stt.service';
|
||||
|
||||
@Controller('ai')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -24,28 +12,21 @@ export class AiController {
|
||||
constructor(
|
||||
private readonly ai: AiService,
|
||||
private readonly ask: AiAskService,
|
||||
private readonly stt: AiSttService,
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
status() {
|
||||
return { enabled: this.ai.isEnabled(), stt: this.stt.isEnabled() };
|
||||
return { enabled: this.ai.isEnabled() };
|
||||
}
|
||||
|
||||
@Post('transcribe')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('audio', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
transcribe(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.stt.transcribe(file);
|
||||
@Post('voice-command')
|
||||
voiceCommand(@CurrentUser() user: Actor, @Body() dto: VoiceCommandDto) {
|
||||
return this.ask.parseVoiceCommand(user, dto.text);
|
||||
}
|
||||
|
||||
@Post('ask')
|
||||
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
|
||||
return this.ask.ask(user, dto.question, dto.personId);
|
||||
return this.ask.ask(user, dto.question, dto.personId, dto.chatContext);
|
||||
}
|
||||
|
||||
@Get('people/:personId/summary')
|
||||
|
||||
@@ -2,12 +2,11 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { AiAskService } from './ai-ask.service';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiSttService } from './ai-stt.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AiAskService, AiSttService],
|
||||
exports: [AiService, AiSttService],
|
||||
providers: [AiService, AiAskService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { rewriteGregorianDates } from '../common/jalali';
|
||||
|
||||
export type NoteAiExtract = {
|
||||
nextAction: {
|
||||
@@ -44,6 +45,7 @@ Rules:
|
||||
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null.
|
||||
- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
|
||||
- Keep text in Persian when the note is Persian.
|
||||
- If summary mentions a date, write it Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.
|
||||
- Do not invent facts not supported by the note.
|
||||
- Maximum 5 memory items.`;
|
||||
|
||||
@@ -61,6 +63,45 @@ Rules:
|
||||
return this.normalizeExtract(raw);
|
||||
}
|
||||
|
||||
async summarizeCallTranscript(input: {
|
||||
transcript: string;
|
||||
personName?: string | null;
|
||||
direction?: string | null;
|
||||
}): Promise<{ summary: string; nextAction: string | null }> {
|
||||
const system = `You summarize phone call transcripts for a Persian CRM.
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
"summary": string,
|
||||
"nextAction": string | null
|
||||
}
|
||||
Rules:
|
||||
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
|
||||
- nextAction: one short Persian follow-up if needed; otherwise null.
|
||||
- Do not invent facts not in the transcript.
|
||||
- Keep names, numbers, and amounts accurate.
|
||||
- Dates in summary and nextAction must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.`;
|
||||
|
||||
const userParts = [
|
||||
input.personName ? `Customer: ${input.personName}` : null,
|
||||
input.direction ? `Direction: ${input.direction}` : null,
|
||||
`Transcript:\n${input.transcript.trim().slice(0, 12000)}`,
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = await this.completeJson<{
|
||||
summary?: string;
|
||||
nextAction?: string | null;
|
||||
}>(system, userParts.join('\n'));
|
||||
|
||||
const summary = rewriteGregorianDates(String(raw.summary ?? '').trim()).slice(0, 4000);
|
||||
if (!summary) {
|
||||
throw new BadRequestException('خلاصه تماس خالی بود');
|
||||
}
|
||||
const nextAction = raw.nextAction
|
||||
? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null
|
||||
: null;
|
||||
return { summary, nextAction };
|
||||
}
|
||||
|
||||
async completeJson<T>(system: string, user: string): Promise<T> {
|
||||
if (!this.isEnabled()) {
|
||||
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
|
||||
@@ -131,9 +172,9 @@ Rules:
|
||||
const nextAction =
|
||||
raw.nextAction && typeof raw.nextAction === 'object'
|
||||
? {
|
||||
title: String(raw.nextAction.title ?? '').trim().slice(0, 200),
|
||||
title: rewriteGregorianDates(String(raw.nextAction.title ?? '').trim()).slice(0, 200),
|
||||
description: raw.nextAction.description
|
||||
? String(raw.nextAction.description).trim().slice(0, 2000)
|
||||
? rewriteGregorianDates(String(raw.nextAction.description).trim()).slice(0, 2000)
|
||||
: undefined,
|
||||
dueAt: raw.nextAction.dueAt
|
||||
? String(raw.nextAction.dueAt).trim()
|
||||
@@ -144,7 +185,7 @@ Rules:
|
||||
const memoryItems = Array.isArray(raw.memoryItems)
|
||||
? raw.memoryItems
|
||||
.map((item) => ({
|
||||
content: String(item?.content ?? '').trim().slice(0, 500),
|
||||
content: rewriteGregorianDates(String(item?.content ?? '').trim()).slice(0, 500),
|
||||
category: item?.category
|
||||
? String(item.category).trim().slice(0, 40)
|
||||
: undefined,
|
||||
@@ -159,7 +200,7 @@ Rules:
|
||||
return {
|
||||
nextAction: cleanedNext,
|
||||
memoryItems,
|
||||
summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined,
|
||||
summary: raw.summary ? rewriteGregorianDates(String(raw.summary).trim()).slice(0, 500) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
PERSON_IMPORT_SOURCES,
|
||||
type PersonImportSource,
|
||||
} from '@mento/shared';
|
||||
|
||||
export type CountEntity =
|
||||
| 'lead'
|
||||
| 'person'
|
||||
| 'company'
|
||||
| 'deal'
|
||||
| 'invoice'
|
||||
| 'quote'
|
||||
| 'product'
|
||||
| 'activity'
|
||||
| 'ticket'
|
||||
| 'note'
|
||||
| 'cheque'
|
||||
| 'reservation'
|
||||
| 'routine'
|
||||
| 'project';
|
||||
|
||||
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'last_week' | 'month' | 'last_month';
|
||||
|
||||
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
|
||||
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
|
||||
|
||||
export type CountIntent = {
|
||||
entity: CountEntity;
|
||||
personSource?: PersonImportSource;
|
||||
mood?: 'newcomer' | 'happy' | 'unhappy' | 'special';
|
||||
leadBucket: LeadBucket;
|
||||
dealBucket: DealBucket;
|
||||
period?: CountPeriod;
|
||||
/** متن باقیمانده برای تطبیق مرحله یا منبع لید */
|
||||
hint: string;
|
||||
};
|
||||
|
||||
const SOURCE_PATTERNS: Array<{ re: RegExp; source: PersonImportSource }> = [
|
||||
{ re: /دستی/, source: 'manual' },
|
||||
{ re: /اکسل/, source: 'excel' },
|
||||
{ re: /دیدار/, source: 'didar' },
|
||||
{ re: /ته\s*حساب/, source: 'tahesab' },
|
||||
{ re: /گوشی|مخاطب\s*گوشی|مخاطبین/, source: 'phone' },
|
||||
{ re: /فرم/, source: 'lead_form' },
|
||||
{ re: /مونتیفای/, source: 'montify' },
|
||||
{ re: /سایر|بقیه/, source: 'other' },
|
||||
];
|
||||
|
||||
export function normalizeAskText(raw: string) {
|
||||
return raw
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(//g, ' ')
|
||||
.replace(/[؟?!.,،؛:]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isCountQuestion(q: string) {
|
||||
return /چند|تعداد|چقدر|چنتا|چند\s*تا/.test(q);
|
||||
}
|
||||
|
||||
function detectEntity(q: string): CountEntity | null {
|
||||
if (/لید|سرنخ/.test(q)) return 'lead';
|
||||
if (/پیش\s*فاکتور/.test(q)) return 'quote';
|
||||
if (/فاکتور/.test(q)) return 'invoice';
|
||||
if (/معاملات|معامله/.test(q)) return 'deal';
|
||||
if (/چک/.test(q)) return 'cheque';
|
||||
if (/رزرو|نوبت/.test(q)) return 'reservation';
|
||||
if (/روتین/.test(q)) return 'routine';
|
||||
if (/پروژه/.test(q)) return 'project';
|
||||
if (/تیکت/.test(q)) return 'ticket';
|
||||
if (/یادداشت/.test(q)) return 'note';
|
||||
if (/فعالیت/.test(q)) return 'activity';
|
||||
if (/محصول|کالا/.test(q)) return 'product';
|
||||
if (/شرکت/.test(q) && !/مشتری/.test(q)) return 'company';
|
||||
if (/مشتری|اشخاص|شخص ها|افراد|مخاطب/.test(q)) return 'person';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectPeriod(q: string): CountPeriod | undefined {
|
||||
if (/دیروز/.test(q)) return 'yesterday';
|
||||
if (/امروز/.test(q)) return 'today';
|
||||
if (/هفته\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_week';
|
||||
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
|
||||
if (/ماه\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_month';
|
||||
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function detectLeadBucket(q: string): LeadBucket {
|
||||
if (/بایگانی/.test(q)) return 'archived';
|
||||
if (/تبدیل/.test(q)) return 'converted';
|
||||
if (/باز|فعال|جاری/.test(q)) return 'open';
|
||||
return 'summary';
|
||||
}
|
||||
|
||||
function detectDealBucket(q: string): DealBucket {
|
||||
if (/در\s*خطر|راکد|گندیده/.test(q)) return 'stale';
|
||||
if (/برنده|موفق|بسته\s*شده\s*موفق/.test(q)) return 'won';
|
||||
if (/باخته|از\s*دست\s*رفته|ناموفق/.test(q)) return 'lost';
|
||||
if (/باز|جاری|فعال/.test(q)) return 'open';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function detectMood(q: string): CountIntent['mood'] {
|
||||
if (/ناراضی/.test(q)) return 'unhappy';
|
||||
if (/خوشحال/.test(q)) return 'happy';
|
||||
if (/خاص/.test(q)) return 'special';
|
||||
if (/تازه/.test(q)) return 'newcomer';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const HINT_STOP = [
|
||||
'چند تا',
|
||||
'ته حساب',
|
||||
'مخاطب گوشی',
|
||||
'مخاطبین',
|
||||
'پیش فاکتور',
|
||||
'بایگانی',
|
||||
'مونتیفای',
|
||||
'معاملات',
|
||||
'مشتریان',
|
||||
'سرنخ',
|
||||
'لیدها',
|
||||
'فعالیت',
|
||||
'یادداشت',
|
||||
'بگویید',
|
||||
'داریم',
|
||||
'دارید',
|
||||
'هستند',
|
||||
'تعداد',
|
||||
'چقدر',
|
||||
'چنتا',
|
||||
'امروز',
|
||||
'دیروز',
|
||||
'جاری',
|
||||
'تبدیل',
|
||||
'برنده',
|
||||
'باخته',
|
||||
'ناراضی',
|
||||
'خوشحال',
|
||||
'محصول',
|
||||
'فاکتور',
|
||||
'معامله',
|
||||
'مشتری',
|
||||
'شرکت',
|
||||
'پروژه',
|
||||
'روتین',
|
||||
'رزرو',
|
||||
'نوبت',
|
||||
'تیکت',
|
||||
'منبع',
|
||||
'مرحله',
|
||||
'وضعیت',
|
||||
'هفته',
|
||||
'گذشته',
|
||||
'پیش',
|
||||
'قبل',
|
||||
'لطفا',
|
||||
'الان',
|
||||
'شده',
|
||||
'وارد',
|
||||
'تازه',
|
||||
'خاص',
|
||||
'بگو',
|
||||
'دارم',
|
||||
'هست',
|
||||
'است',
|
||||
'جاری',
|
||||
'فعال',
|
||||
'باز',
|
||||
'لید',
|
||||
'کالا',
|
||||
'چک',
|
||||
'ماه',
|
||||
'پیش',
|
||||
'های',
|
||||
'چند',
|
||||
'کل',
|
||||
'همه',
|
||||
'این',
|
||||
'یک',
|
||||
'یه',
|
||||
'از',
|
||||
'با',
|
||||
'در',
|
||||
'تا',
|
||||
'ها',
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
const PERSON_HINT_STOP = [
|
||||
'ته حساب',
|
||||
'مخاطب گوشی',
|
||||
'مخاطبین',
|
||||
'مونتیفای',
|
||||
'ناراضی',
|
||||
'خوشحال',
|
||||
'دستی',
|
||||
'اکسل',
|
||||
'دیدار',
|
||||
'گوشی',
|
||||
'مخاطب',
|
||||
'فرم',
|
||||
'سایر',
|
||||
'تازه',
|
||||
'خاص',
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
function leftoverHint(q: string, entity: CountEntity) {
|
||||
let text = ` ${q} `;
|
||||
const words = entity === 'person' || entity === 'company'
|
||||
? [...HINT_STOP, ...PERSON_HINT_STOP]
|
||||
: HINT_STOP;
|
||||
for (const word of [...words].sort((a, b) => b.length - a.length)) {
|
||||
text = text.replaceAll(word, ' ');
|
||||
}
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function classifyCountQuestion(raw: string): CountIntent | null {
|
||||
const q = normalizeAskText(raw);
|
||||
if (!isCountQuestion(q)) return null;
|
||||
const entity = detectEntity(q);
|
||||
if (!entity) return null;
|
||||
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
|
||||
const period = detectPeriod(q);
|
||||
let hint = leftoverHint(q, entity);
|
||||
if (period && entity === 'lead' && !/مرحله|وضعیت|ستون/.test(q)) {
|
||||
hint = hint.replace(/(^|\s)جدید(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
return {
|
||||
entity,
|
||||
personSource:
|
||||
entity === 'person' || entity === 'company' ? personSource : undefined,
|
||||
mood: entity === 'person' ? detectMood(q) : undefined,
|
||||
leadBucket: detectLeadBucket(q),
|
||||
dealBucket: detectDealBucket(q),
|
||||
period,
|
||||
hint,
|
||||
};
|
||||
}
|
||||
|
||||
export function personSourceKeys(): readonly PersonImportSource[] {
|
||||
return PERSON_IMPORT_SOURCES;
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class VoiceCommandDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(800)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class AskMentoDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@@ -9,4 +16,9 @@ export class AskMentoDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
chatContext?: string;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,28 @@ import { LoyaltyModule } from './loyalty/loyalty.module';
|
||||
import { AutomationsModule } from './automations/automations.module';
|
||||
import { MessageAutomationsModule } from './message-automations/message-automations.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { TrackingModule } from './tracking/tracking.module';
|
||||
import { MemoryModule } from './memory/memory.module';
|
||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
||||
import { SavedViewsModule } from './saved-views/saved-views.module';
|
||||
import { TablePrefsModule } from './table-prefs/table-prefs.module';
|
||||
import { PublicApiModule } from './public-api/public-api.module';
|
||||
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
|
||||
import { TeamsModule } from './teams/teams.module';
|
||||
import { WooCommerceModule } from './integrations/woocommerce/woocommerce.module';
|
||||
import { MontifyModule } from './integrations/montify/montify.module';
|
||||
import { VoipModule } from './integrations/voip/voip.module';
|
||||
import { ReservationsModule } from './reservations/reservations.module';
|
||||
import { LeadsModule } from './leads/leads.module';
|
||||
import { ConversationsModule } from './conversations/conversations.module';
|
||||
import { RoutinesModule } from './routines/routines.module';
|
||||
import { GamificationModule } from './gamification/gamification.module';
|
||||
import { DeletionsModule } from './deletions/deletions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,11 +62,14 @@ import { FinanceModule } from './finance/finance.module';
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
BillingModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
UsersModule,
|
||||
OrgRolesModule,
|
||||
OrganizationsModule,
|
||||
ContactsModule,
|
||||
LeadsModule,
|
||||
PipelinesModule,
|
||||
ActivitiesModule,
|
||||
NotesModule,
|
||||
@@ -71,6 +92,21 @@ import { FinanceModule } from './finance/finance.module';
|
||||
MessageAutomationsModule,
|
||||
MemoryModule,
|
||||
TahesabModule,
|
||||
AuditLogsModule,
|
||||
SavedViewsModule,
|
||||
TablePrefsModule,
|
||||
PublicApiModule,
|
||||
SmsCampaignsModule,
|
||||
TrackingModule,
|
||||
TeamsModule,
|
||||
WooCommerceModule,
|
||||
MontifyModule,
|
||||
VoipModule,
|
||||
ReservationsModule,
|
||||
ConversationsModule,
|
||||
RoutinesModule,
|
||||
GamificationModule,
|
||||
DeletionsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
title: 'عنوان',
|
||||
name: 'نام',
|
||||
firstName: 'نام',
|
||||
lastName: 'نام خانوادگی',
|
||||
fullName: 'نام',
|
||||
description: 'توضیحات',
|
||||
body: 'متن',
|
||||
mobile: 'موبایل',
|
||||
phone: 'تلفن',
|
||||
email: 'ایمیل',
|
||||
status: 'وضعیت',
|
||||
stageId: 'مرحله',
|
||||
priority: 'اولویت',
|
||||
dueAt: 'موعد',
|
||||
amount: 'مبلغ',
|
||||
subject: 'موضوع',
|
||||
type: 'نوع',
|
||||
city: 'شهر',
|
||||
address: 'آدرس',
|
||||
notes: 'یادداشت',
|
||||
contactName: 'نام مخاطب',
|
||||
};
|
||||
|
||||
const SECRET = /password|token|secret|otp|pin/i;
|
||||
|
||||
function textOf(value: unknown) {
|
||||
if (typeof value === 'string') return value.trim().replace(/\s+/g, ' ');
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function labelOf(source: unknown) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
|
||||
const body = source as Record<string, unknown>;
|
||||
const person = [textOf(body.firstName), textOf(body.lastName)].filter(Boolean).join(' ');
|
||||
return (
|
||||
person ||
|
||||
textOf(body.title) ||
|
||||
textOf(body.name) ||
|
||||
textOf(body.subject) ||
|
||||
textOf(body.fullName)
|
||||
).slice(0, 120);
|
||||
}
|
||||
|
||||
function changesOf(source: unknown) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
|
||||
const bits: string[] = [];
|
||||
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
||||
if (SECRET.test(key)) continue;
|
||||
const name = FIELD_LABELS[key];
|
||||
if (!name) continue;
|
||||
if (key.endsWith('Id')) {
|
||||
bits.push(name);
|
||||
} else {
|
||||
const text = textOf(value).slice(0, 40);
|
||||
if (!text) continue;
|
||||
bits.push(`${name}: ${text}`);
|
||||
}
|
||||
if (bits.length >= 4) break;
|
||||
}
|
||||
return bits.join('، ');
|
||||
}
|
||||
|
||||
export function auditStory(
|
||||
method: string,
|
||||
requestBody: unknown,
|
||||
responseBody: unknown,
|
||||
) {
|
||||
const label = labelOf(responseBody) || labelOf(requestBody);
|
||||
const detail = method === 'DELETE' ? '' : changesOf(requestBody);
|
||||
const summary = [label, detail].filter(Boolean).join(' — ').slice(0, 500);
|
||||
return {
|
||||
label,
|
||||
detail: detail.slice(0, 240),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
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 { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
class ListAuditQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
entityType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Controller('audit-logs')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('audit_logs')
|
||||
@Roles('owner', 'admin')
|
||||
export class AuditLogsController {
|
||||
constructor(private readonly auditLogs: AuditLogsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListAuditQueryDto,
|
||||
) {
|
||||
return this.auditLogs.list(user.organizationId, {
|
||||
q: query.q,
|
||||
actorUserId: query.actorUserId,
|
||||
entityType: query.entityType,
|
||||
action: query.action,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { auditStory } from './audit-detail';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
const SKIP_PREFIXES = [
|
||||
'/api/health',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/auth/otp',
|
||||
'/api/auth/refresh',
|
||||
'/api/portal',
|
||||
'/api/public',
|
||||
'/api/platform',
|
||||
'/api/audit-logs',
|
||||
];
|
||||
|
||||
type AuthedRequest = Request & {
|
||||
user?: {
|
||||
userId?: string;
|
||||
id?: string;
|
||||
organizationId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function inferEntity(path: string): { entityType: string | null; entityId: string | null } {
|
||||
const clean = path.replace(/^\/api\/?/, '').split('?')[0] ?? '';
|
||||
const parts = clean.split('/').filter(Boolean);
|
||||
if (!parts.length) return { entityType: null, entityId: null };
|
||||
const entityType = parts[0] ?? null;
|
||||
const maybeId = parts[1];
|
||||
const entityId =
|
||||
maybeId && !['new', 'me', 'summary', 'dashboard', 'board'].includes(maybeId)
|
||||
? maybeId
|
||||
: null;
|
||||
return { entityType, entityId };
|
||||
}
|
||||
|
||||
function actionLabel(method: string, path: string): string {
|
||||
const m = method.toUpperCase();
|
||||
if (m === 'POST') return 'ایجاد';
|
||||
if (m === 'PATCH' || m === 'PUT') return 'ویرایش';
|
||||
if (m === 'DELETE') return 'حذف';
|
||||
return m;
|
||||
void path;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogsInterceptor implements NestInterceptor {
|
||||
constructor(private readonly audit: AuditLogsService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<AuthedRequest>();
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const path = req.originalUrl || req.url || '';
|
||||
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const organizationId = req.user?.organizationId;
|
||||
if (!organizationId) return next.handle();
|
||||
|
||||
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
|
||||
const { entityType, entityId } = inferEntity(path);
|
||||
const action = actionLabel(method, path);
|
||||
const ip =
|
||||
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
|
||||
req.ip ||
|
||||
null;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: (response) => {
|
||||
const story = auditStory(method, req.body, response);
|
||||
const summary =
|
||||
story.summary ||
|
||||
`${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
|
||||
void this.audit.write({
|
||||
organizationId,
|
||||
actorUserId,
|
||||
action,
|
||||
method,
|
||||
path: path.split('?')[0] ?? path,
|
||||
entityType,
|
||||
entityId,
|
||||
summary,
|
||||
meta: story.label || story.detail ? { label: story.label, detail: story.detail } : null,
|
||||
ip,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { AuditLogsController } from './audit-logs.controller';
|
||||
import { AuditLogsInterceptor } from './audit-logs.interceptor';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
controllers: [AuditLogsController],
|
||||
providers: [
|
||||
AuditLogsService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: AuditLogsInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [AuditLogsService],
|
||||
})
|
||||
export class AuditLogsModule {}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AuditLogDto, AuditLogListResponse } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
|
||||
export type AuditWriteInput = {
|
||||
organizationId: string;
|
||||
actorUserId?: string | null;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
summary: string;
|
||||
meta?: Record<string, unknown> | null;
|
||||
ip?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async write(input: AuditWriteInput): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
action: input.action,
|
||||
method: input.method.slice(0, 16),
|
||||
path: input.path.slice(0, 400),
|
||||
entityType: input.entityType ?? null,
|
||||
entityId: input.entityId ?? null,
|
||||
summary: input.summary.slice(0, 500),
|
||||
meta:
|
||||
input.meta == null
|
||||
? undefined
|
||||
: (input.meta as Prisma.InputJsonValue),
|
||||
ip: input.ip ?? null,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// never break the main request because of audit write failures
|
||||
}
|
||||
}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts: {
|
||||
q?: string;
|
||||
actorUserId?: string;
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<AuditLogListResponse> {
|
||||
const page = Math.max(1, opts.page ?? 1);
|
||||
const limit = Math.min(100, Math.max(1, opts.limit ?? 30));
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
organizationId,
|
||||
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
|
||||
...(opts.entityType ? { entityType: opts.entityType } : {}),
|
||||
...(opts.action ? { action: opts.action } : {}),
|
||||
};
|
||||
const search = buildSearchWhere<Prisma.AuditLogWhereInput>(opts.q, (t) => [
|
||||
{ summary: ci(t) },
|
||||
{ path: ci(t) },
|
||||
{ action: ci(t) },
|
||||
{ actor: { fullName: ci(t) } },
|
||||
]);
|
||||
if (search) where.AND = [search];
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
this.prisma.auditLog.count({ where }),
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { actor: { select: { id: true, fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
summary: string;
|
||||
meta: Prisma.JsonValue | null;
|
||||
ip: string | null;
|
||||
createdAt: Date;
|
||||
actor: { id: string; fullName: string } | null;
|
||||
}): AuditLogDto {
|
||||
const meta =
|
||||
row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta)
|
||||
? (row.meta as { label?: unknown; detail?: unknown })
|
||||
: null;
|
||||
const label = typeof meta?.label === 'string' ? meta.label : null;
|
||||
const detail = typeof meta?.detail === 'string' ? meta.detail : null;
|
||||
return {
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
summary: row.summary,
|
||||
label,
|
||||
detail,
|
||||
actor: row.actor,
|
||||
ip: row.ip,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -25,8 +26,7 @@ import {
|
||||
} from './dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
|
||||
type AuthActor = { userId: string; organizationId: string };
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -68,7 +68,7 @@ export class AuthController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('switch-organization')
|
||||
switchOrganization(
|
||||
@CurrentUser() user: AuthActor,
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: SwitchOrganizationDto,
|
||||
) {
|
||||
return this.authService.switchOrganization(user.userId, dto.organizationId);
|
||||
@@ -76,26 +76,55 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('organizations')
|
||||
listOrganizations(@CurrentUser() user: AuthActor) {
|
||||
listOrganizations(@CurrentUser() user: Actor) {
|
||||
return this.authService.listMyOrganizations(user.userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: AuthActor) {
|
||||
return this.authService.me(user.userId, user.organizationId);
|
||||
me(@CurrentUser() user: Actor) {
|
||||
return this.authService.me(user.userId, user.organizationId, user);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('view-as')
|
||||
viewAs(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { targetUserId?: string },
|
||||
) {
|
||||
if (!body.targetUserId?.trim()) {
|
||||
throw new BadRequestException('کاربر هدف مشخص نیست.');
|
||||
}
|
||||
return this.authService.viewAs(user, body.targetUserId.trim());
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me')
|
||||
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
|
||||
updateMe(@CurrentUser() user: Actor, @Body() dto: UpdateProfileDto) {
|
||||
return this.authService.updateMe(user.userId, user.organizationId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me/prior-archive')
|
||||
setPriorLens(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { lens?: string },
|
||||
) {
|
||||
if (body.lens !== 'archive' && body.lens !== 'current') {
|
||||
throw new BadRequestException('حالت بایگانی نامعتبر است');
|
||||
}
|
||||
return this.authService.setPriorLens(
|
||||
user.userId,
|
||||
user.organizationId,
|
||||
body.lens,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
/** عکس پروفایل کاربر جاری */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me/avatar')
|
||||
async avatar(@CurrentUser() user: AuthActor) {
|
||||
async avatar(@CurrentUser() user: Actor) {
|
||||
const file = await this.authService.openAvatar(user.userId);
|
||||
return new StreamableFile(file.stream, { type: file.mimeType });
|
||||
}
|
||||
@@ -109,7 +138,7 @@ export class AuthController {
|
||||
}),
|
||||
)
|
||||
uploadAvatar(
|
||||
@CurrentUser() user: AuthActor,
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.authService.saveAvatar(user.userId, user.organizationId, file);
|
||||
@@ -117,7 +146,7 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('me/avatar')
|
||||
removeAvatar(@CurrentUser() user: AuthActor) {
|
||||
removeAvatar(@CurrentUser() user: Actor) {
|
||||
return this.authService.removeAvatar(user.userId, user.organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MembershipsModule } from '../memberships/memberships.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
MembershipsModule,
|
||||
BillingModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
@@ -26,7 +30,16 @@ import { SmsService } from '../common/sms.service';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, RolesGuard, SmsService],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
RolesGuard,
|
||||
SmsService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ViewAsReadOnlyInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [AuthService, RolesGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -10,12 +11,22 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as path from 'path';
|
||||
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
|
||||
import type {
|
||||
AuthUserDto,
|
||||
LoginResponse,
|
||||
LoginResult,
|
||||
OrgModuleKey,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||
import type {} from 'multer';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginOtpRequestDto,
|
||||
@@ -26,7 +37,7 @@ import {
|
||||
} from './dto';
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
const OTP_TTL_SECONDS = 5 * 60;
|
||||
const OTP_TTL_SECONDS = 90;
|
||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const AVATAR_EXT: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -70,7 +81,8 @@ function maskEmail(email: string): string {
|
||||
|
||||
function maskMobile(mobile: string): string {
|
||||
if (mobile.length < 4) return '****';
|
||||
return `${mobile.slice(0, 4)}***${mobile.slice(-2)}`;
|
||||
// نمایش RTL: دو رقم آخر سپس *** سپس پیششماره (مثلاً 69***0913)
|
||||
return `${mobile.slice(-2)}***${mobile.slice(0, 4)}`;
|
||||
}
|
||||
|
||||
function generateOtp(): string {
|
||||
@@ -97,6 +109,7 @@ export class AuthService {
|
||||
private readonly sms: SmsService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -260,6 +273,11 @@ export class AuthService {
|
||||
organizationId: organization.id,
|
||||
role: 'owner',
|
||||
});
|
||||
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organization.id },
|
||||
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||
});
|
||||
|
||||
await this.prisma.authChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
@@ -389,13 +407,130 @@ export class AuthService {
|
||||
return this.finishLogin(user.id);
|
||||
}
|
||||
|
||||
async me(userId: string, organizationId: string): Promise<AuthUserDto> {
|
||||
async me(userId: string, organizationId: string, actor?: Actor): Promise<AuthUserDto> {
|
||||
await this.memberships.requireActive(userId, organizationId);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return this.toAuthUserDto(user, organizationId);
|
||||
let impersonatorFullName: string | undefined;
|
||||
if (actor?.viewAsReadOnly && actor.impersonatorUserId) {
|
||||
const owner = await this.prisma.user.findUnique({
|
||||
where: { id: actor.impersonatorUserId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
impersonatorFullName = owner?.fullName;
|
||||
}
|
||||
return this.toAuthUserDto(user, organizationId, undefined, actor, impersonatorFullName);
|
||||
}
|
||||
|
||||
/**
|
||||
* مالک محیط: مشاهده فقطخواندنی بهجای کاربر هدف، یا بازگشت به حساب خودش.
|
||||
*/
|
||||
async viewAs(actor: Actor, targetUserId: string): Promise<LoginResponse> {
|
||||
const ownerId = actor.impersonatorUserId ?? actor.userId;
|
||||
const ownerMembership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: ownerId,
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
role: 'owner',
|
||||
},
|
||||
select: { userId: true, organizationId: true },
|
||||
});
|
||||
if (!ownerMembership) {
|
||||
throw new ForbiddenException('فقط مالک محیط میتواند بهجای کاربر دیگر مشاهده کند.');
|
||||
}
|
||||
|
||||
if (!targetUserId?.trim()) {
|
||||
throw new BadRequestException('کاربر هدف مشخص نیست.');
|
||||
}
|
||||
|
||||
if (targetUserId === ownerId) {
|
||||
return this.issueLogin(ownerId, actor.organizationId, { skipLastLogin: true });
|
||||
}
|
||||
|
||||
const target = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: targetUserId,
|
||||
isActive: true,
|
||||
memberships: {
|
||||
some: {
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
fullName: true,
|
||||
memberships: {
|
||||
where: { organizationId: actor.organizationId, isActive: true },
|
||||
select: { role: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!target?.memberships[0]) {
|
||||
throw new NotFoundException('کاربر در این محیط پیدا نشد.');
|
||||
}
|
||||
|
||||
const role = target.memberships[0].role as AuthUserDto['role'];
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
actor.organizationId,
|
||||
);
|
||||
const accessToken = this.jwt.sign({
|
||||
sub: target.id,
|
||||
organizationId: actor.organizationId,
|
||||
role,
|
||||
impersonatorUserId: ownerId,
|
||||
viewAsReadOnly: true,
|
||||
});
|
||||
const ownerUser = await this.prisma.user.findUnique({
|
||||
where: { id: ownerId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
const userDto = await this.toAuthUserDto(
|
||||
{ ...target, role, organizationId: actor.organizationId },
|
||||
actor.organizationId,
|
||||
subscription,
|
||||
{
|
||||
userId: target.id,
|
||||
role,
|
||||
organizationId: actor.organizationId,
|
||||
impersonatorUserId: ownerId,
|
||||
impersonatorRole: 'owner',
|
||||
viewAsReadOnly: true,
|
||||
},
|
||||
ownerUser?.fullName ?? 'مالک',
|
||||
);
|
||||
return { accessToken, user: userDto };
|
||||
}
|
||||
|
||||
async setPriorLens(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
lens: 'current' | 'archive',
|
||||
actor?: Actor,
|
||||
): Promise<AuthUserDto> {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
include: { organization: { select: { enabledModules: true } } },
|
||||
});
|
||||
if (!membership?.priorArchiveEnabled) {
|
||||
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست');
|
||||
}
|
||||
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||
if (!modules.includes('prior_data_archive')) {
|
||||
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id: membership.id },
|
||||
data: { priorArchiveLens: lens },
|
||||
});
|
||||
return this.me(userId, organizationId, actor);
|
||||
}
|
||||
|
||||
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
|
||||
@@ -572,21 +707,27 @@ export class AuthService {
|
||||
private async issueLogin(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
opts?: { skipLastLogin?: boolean },
|
||||
): Promise<LoginResponse> {
|
||||
const membership = await this.memberships.requireActive(userId, organizationId);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
organizationId,
|
||||
);
|
||||
await this.memberships.syncUserLastOrg(
|
||||
userId,
|
||||
organizationId,
|
||||
membership.role,
|
||||
);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
if (!opts?.skipLastLogin) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
const accessToken = this.jwt.sign({
|
||||
sub: userId,
|
||||
organizationId,
|
||||
@@ -597,6 +738,7 @@ export class AuthService {
|
||||
user: await this.toAuthUserDto(
|
||||
{ ...user, role: membership.role, organizationId },
|
||||
organizationId,
|
||||
subscription,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -611,22 +753,64 @@ export class AuthService {
|
||||
organizationId: string;
|
||||
},
|
||||
organizationId: string,
|
||||
subscription?: SubscriptionSummaryDto | null,
|
||||
actor?: Actor,
|
||||
impersonatorFullName?: string,
|
||||
): Promise<AuthUserDto> {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: user.id, organizationId, isActive: true },
|
||||
include: { organization: { select: { name: true } } },
|
||||
include: {
|
||||
organization: {
|
||||
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
const orgName = membership?.organization.name;
|
||||
const sub =
|
||||
subscription ??
|
||||
(await this.billing.requireActiveForOrganization(organizationId));
|
||||
const viewAs =
|
||||
actor?.viewAsReadOnly && actor.impersonatorUserId
|
||||
? {
|
||||
impersonatorId: actor.impersonatorUserId,
|
||||
impersonatorFullName: impersonatorFullName?.trim() || 'مالک',
|
||||
canOpenTeamSettings: true,
|
||||
}
|
||||
: undefined;
|
||||
return this.toAuthUser({
|
||||
...user,
|
||||
role,
|
||||
organizationId,
|
||||
organizationName: orgName,
|
||||
avatarStorageKey: await this.readAvatarKey(user.id),
|
||||
enabledModules: this.billing.normalizeModules(
|
||||
membership?.organization.enabledModules,
|
||||
),
|
||||
subscription: sub,
|
||||
isOrgOwner: role === 'owner',
|
||||
priorArchive: this.priorArchiveFor(membership),
|
||||
...(viewAs ? { viewAs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private priorArchiveFor(
|
||||
membership: {
|
||||
priorArchiveEnabled?: boolean;
|
||||
priorArchiveLens?: string;
|
||||
organization?: { enabledModules?: string[]; priorArchiveEnabledAt?: Date | null };
|
||||
} | null,
|
||||
): AuthUserDto['priorArchive'] {
|
||||
if (!membership?.priorArchiveEnabled) return undefined;
|
||||
const modules = this.billing.normalizeModules(membership.organization?.enabledModules);
|
||||
if (!modules.includes('prior_data_archive')) return undefined;
|
||||
const orgCutoff = membership.organization?.priorArchiveEnabledAt ?? null;
|
||||
return {
|
||||
lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current',
|
||||
cutoffAt: orgCutoff ? orgCutoff.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
private async throttleChallenges(
|
||||
kind: string,
|
||||
email: string | null | undefined,
|
||||
@@ -708,6 +892,11 @@ export class AuthService {
|
||||
organizationId: string;
|
||||
organizationName?: string;
|
||||
avatarStorageKey?: string | null;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
isOrgOwner?: boolean;
|
||||
priorArchive?: AuthUserDto['priorArchive'];
|
||||
viewAs?: AuthUserDto['viewAs'];
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -718,6 +907,11 @@ export class AuthService {
|
||||
organizationName: user.organizationName,
|
||||
hasAvatar: Boolean(user.avatarStorageKey),
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { DataVisibility } from '@mento/shared';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import {
|
||||
type Actor,
|
||||
normalizeDataVisibility,
|
||||
parseVisibilityScopes,
|
||||
} from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
organizationId: string;
|
||||
role: string;
|
||||
impersonatorUserId?: string;
|
||||
viewAsReadOnly?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -15,6 +25,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@@ -24,12 +35,38 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
||||
if (payload.typ === 'portal' || payload.typ === 'org-select') {
|
||||
if (
|
||||
payload.typ === 'portal' ||
|
||||
payload.typ === 'org-select' ||
|
||||
payload.typ === 'platform-admin'
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (!payload.sub || !payload.organizationId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
let impersonatorUserId: string | undefined;
|
||||
let impersonatorRole: string | undefined;
|
||||
const viewAsReadOnly = Boolean(payload.viewAsReadOnly && payload.impersonatorUserId);
|
||||
if (viewAsReadOnly && payload.impersonatorUserId) {
|
||||
const ownerMembership = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: payload.impersonatorUserId,
|
||||
organizationId: payload.organizationId,
|
||||
isActive: true,
|
||||
role: 'owner',
|
||||
organization: { isActive: true },
|
||||
},
|
||||
select: { userId: true, role: true },
|
||||
});
|
||||
if (!ownerMembership) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
impersonatorUserId = ownerMembership.userId;
|
||||
impersonatorRole = ownerMembership.role;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, isActive: true },
|
||||
@@ -42,16 +79,88 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
userId: user.id,
|
||||
organizationId: payload.organizationId,
|
||||
isActive: true,
|
||||
organization: { isActive: true },
|
||||
},
|
||||
select: {
|
||||
organizationId: true,
|
||||
role: true,
|
||||
dataVisibility: true,
|
||||
dataVisibilityScopes: true,
|
||||
priorArchiveEnabled: true,
|
||||
priorArchiveLens: true,
|
||||
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
|
||||
},
|
||||
select: { organizationId: true, role: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
await this.billing.requireActiveForOrganization(membership.organizationId);
|
||||
|
||||
const dataVisibility = normalizeDataVisibility(membership.dataVisibility);
|
||||
const scopeLevels = parseVisibilityScopes(membership.dataVisibilityScopes);
|
||||
const elevatedRole = canViewAllRecords(membership.role);
|
||||
|
||||
let teamIds: string[] | undefined;
|
||||
const resolve = async (level: DataVisibility): Promise<string[] | null> => {
|
||||
if (elevatedRole || level === 'org') return null;
|
||||
if (level === 'own') return [user.id];
|
||||
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
|
||||
return teamIds;
|
||||
};
|
||||
|
||||
const visibleUserIds = await resolve(dataVisibility);
|
||||
const scopes: Actor['scopes'] = {};
|
||||
if (!elevatedRole) {
|
||||
for (const scope of DATA_SCOPES) {
|
||||
const level = scopeLevels[scope];
|
||||
if (!level || level === dataVisibility) continue;
|
||||
scopes[scope] = { dataVisibility: level, visibleUserIds: await resolve(level) };
|
||||
}
|
||||
}
|
||||
|
||||
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||
const priorGranted =
|
||||
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
||||
const priorLens = membership.priorArchiveLens === 'archive' ? 'archive' : 'current';
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
organizationId: membership.organizationId,
|
||||
role: membership.role,
|
||||
dataVisibility,
|
||||
visibleUserIds,
|
||||
scopes,
|
||||
priorLens: priorGranted ? priorLens : 'off',
|
||||
priorCutoff:
|
||||
priorGranted && membership.organization.priorArchiveEnabledAt
|
||||
? membership.organization.priorArchiveEnabledAt
|
||||
: null,
|
||||
...(viewAsReadOnly && impersonatorUserId
|
||||
? {
|
||||
impersonatorUserId,
|
||||
impersonatorRole: impersonatorRole ?? 'owner',
|
||||
viewAsReadOnly: true,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async teamPeerIds(organizationId: string, userId: string): Promise<string[]> {
|
||||
const memberships = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
userId,
|
||||
team: { organizationId },
|
||||
},
|
||||
select: { teamId: true },
|
||||
});
|
||||
if (!memberships.length) return [userId];
|
||||
|
||||
const peers = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId: { in: memberships.map((m) => m.teamId) },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
return [...new Set([userId, ...peers.map((p) => p.userId)])];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,26 +6,79 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
const VIEW_AS_MESSAGE =
|
||||
'در حالت مشاهده بهجای کاربر دیگر امکان تغییر وجود ندارد.';
|
||||
|
||||
/** خواندنهای صفحهٔ تیم وقتی مالک در حالت مشاهده است. */
|
||||
function isTeamSettingsRead(method: string, url: string): boolean {
|
||||
if (method.toUpperCase() !== 'GET') return false;
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
||||
return (
|
||||
/\/users(\/|$)/.test(path) ||
|
||||
/\/org-roles(\/|$)/.test(path) ||
|
||||
/\/teams(\/|$)/.test(path) ||
|
||||
/\/organizations\/current/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isViewAsAllowedMutation(method: string, url: string): boolean {
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
|
||||
const m = method.toUpperCase();
|
||||
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
|
||||
return true;
|
||||
}
|
||||
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const method = (request.method || 'GET').toUpperCase();
|
||||
const url = request.originalUrl || request.url || '';
|
||||
const actor = request.user;
|
||||
|
||||
if (
|
||||
actor?.viewAsReadOnly &&
|
||||
method !== 'GET' &&
|
||||
method !== 'HEAD' &&
|
||||
method !== 'OPTIONS' &&
|
||||
!isViewAsAllowedMutation(method, url)
|
||||
) {
|
||||
throw new ForbiddenException(VIEW_AS_MESSAGE);
|
||||
}
|
||||
|
||||
const roles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!roles?.length) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: { role?: string };
|
||||
}>();
|
||||
const role = request.user?.role as UserRole | undefined;
|
||||
if (!role || !roles.includes(role)) {
|
||||
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
|
||||
const role = actor?.role as UserRole | undefined;
|
||||
if (role && roles.includes(role)) return true;
|
||||
|
||||
if (
|
||||
actor?.viewAsReadOnly &&
|
||||
actor.impersonatorRole === 'owner' &&
|
||||
roles.includes('owner') &&
|
||||
isTeamSettingsRead(method, url)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
throw new ForbiddenException('دسترسی به این بخش برای نقش شما مجاز نیست');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { Observable } from 'rxjs';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
const VIEW_AS_MESSAGE =
|
||||
'در حالت مشاهده بهجای کاربر دیگر امکان تغییر وجود ندارد.';
|
||||
|
||||
function normalizePath(url: string): string {
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '') || '/';
|
||||
return path;
|
||||
}
|
||||
|
||||
/** مسیرهایی که در حالت مشاهده مجاز به تغییرند (خروج از مشاهده و لنز بایگانی). */
|
||||
function isAllowedWhileViewing(method: string, url: string): boolean {
|
||||
const path = normalizePath(url);
|
||||
const m = method.toUpperCase();
|
||||
if (m === 'POST' && (path.endsWith('/auth/view-as') || path.endsWith('/auth/view-as/stop'))) {
|
||||
return true;
|
||||
}
|
||||
if (m === 'PATCH' && path.endsWith('/auth/me/prior-archive')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ViewAsReadOnlyInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<Request & { user?: Actor }>();
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
||||
return next.handle();
|
||||
}
|
||||
const actor = req.user;
|
||||
if (!actor?.viewAsReadOnly) {
|
||||
return next.handle();
|
||||
}
|
||||
const url = req.originalUrl || req.url || '';
|
||||
if (isAllowedWhileViewing(method, url)) {
|
||||
return next.handle();
|
||||
}
|
||||
throw new ForbiddenException(VIEW_AS_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export type FormFieldLabel = { key: string; label: string };
|
||||
|
||||
export type TemplateVars = {
|
||||
title: string;
|
||||
mobile?: string | null;
|
||||
formName?: string | null;
|
||||
formValues?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
||||
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||
if (!ids.length) return null;
|
||||
const n = Math.max(1, Math.trunc(counter));
|
||||
return ids[(n - 1) % ids.length] ?? null;
|
||||
}
|
||||
|
||||
/** جایگزینی {{title}}، {{mobile}}، {{form}} و {{field.<key>}} */
|
||||
export function renderAutomationTemplate(text: string, vars: TemplateVars): string {
|
||||
return text.replace(/\{\{\s*([\w.\u0600-\u06FF-]+)\s*\}\}/g, (_m, name: string) => {
|
||||
if (name === 'title') return vars.title ?? '';
|
||||
if (name === 'mobile') return vars.mobile ?? '';
|
||||
if (name === 'form') return vars.formName ?? '';
|
||||
if (name.startsWith('field.')) {
|
||||
const v = vars.formValues?.[name.slice(6)];
|
||||
return v == null ? '' : String(v);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFormAnswers(
|
||||
fields: FormFieldLabel[] | null | undefined,
|
||||
values: Record<string, unknown> | null | undefined,
|
||||
): string {
|
||||
if (!values) return '';
|
||||
const lines: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const f of fields ?? []) {
|
||||
seen.add(f.key);
|
||||
const raw = values[f.key];
|
||||
const v = raw == null ? '' : String(raw).trim();
|
||||
if (v) lines.push(`${f.label}: ${v}`);
|
||||
}
|
||||
for (const [key, raw] of Object.entries(values)) {
|
||||
if (seen.has(key)) continue;
|
||||
const v = raw == null ? '' : String(raw).trim();
|
||||
if (v) lines.push(`${key}: ${v}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
AutomationAction,
|
||||
AutomationChainMode,
|
||||
AutomationConditionField,
|
||||
AutomationConditionOp,
|
||||
AutomationNotifyChannel,
|
||||
@@ -19,27 +20,45 @@ import type {
|
||||
import {
|
||||
MAX_AUTOMATION_STEPS,
|
||||
automationStepToConfig,
|
||||
automationUsesAdvanced,
|
||||
firstAutomationAction,
|
||||
isAutomationCondition,
|
||||
formConditionValues,
|
||||
matchFormFieldCondition,
|
||||
normalizeAutomationSteps,
|
||||
packAutomationConfig,
|
||||
planAutomationActions,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import {
|
||||
formatFormAnswers,
|
||||
pickRoundRobin,
|
||||
renderAutomationTemplate,
|
||||
type FormFieldLabel,
|
||||
} from './automation-engine.util';
|
||||
|
||||
export type AutomationContext = {
|
||||
organizationId: string;
|
||||
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity';
|
||||
entityType: 'deal' | 'ticket' | 'person' | 'invoice' | 'activity' | 'lead';
|
||||
entityId: string;
|
||||
title: string;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
leadId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
/** مثلاً id کمپین SMS یا id فرم سرنخ برای فیلتر قانون */
|
||||
sourceId?: string | null;
|
||||
sourceType?: string | null;
|
||||
clickCount?: number | null;
|
||||
mobile?: string | null;
|
||||
formName?: string | null;
|
||||
formValues?: Record<string, unknown> | null;
|
||||
formFields?: FormFieldLabel[] | null;
|
||||
};
|
||||
|
||||
type AutomationFacts = {
|
||||
@@ -47,8 +66,11 @@ type AutomationFacts = {
|
||||
hasPerson: boolean;
|
||||
hasCompany: boolean;
|
||||
ticketPriority: string | null;
|
||||
formValues: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type StepRef = { ruleId: string; stepId: string };
|
||||
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
private readonly logger = new Logger(AutomationsService.name);
|
||||
@@ -75,6 +97,8 @@ export class AutomationsService {
|
||||
const steps = resolveSteps(dto.action, dto.config);
|
||||
const action = firstAutomationAction(steps);
|
||||
if (!action) throw new BadRequestException('حداقل یک عمل لازم است');
|
||||
await this.assertStepRefs(organizationId, steps);
|
||||
await this.assertLevelAllows(organizationId, steps, chainExtra(dto.config).chainMode);
|
||||
const row = await this.prisma.automationRule.create({
|
||||
data: {
|
||||
organizationId,
|
||||
@@ -82,7 +106,7 @@ export class AutomationsService {
|
||||
trigger: dto.trigger,
|
||||
action,
|
||||
isActive: dto.isActive ?? true,
|
||||
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
||||
config: packAutomationConfig(steps, chainExtra(dto.config)) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
@@ -110,6 +134,14 @@ export class AutomationsService {
|
||||
if (steps && !nextAction) {
|
||||
throw new BadRequestException('حداقل یک عمل لازم است');
|
||||
}
|
||||
if (steps) {
|
||||
await this.assertStepRefs(organizationId, steps);
|
||||
await this.assertLevelAllows(
|
||||
organizationId,
|
||||
steps,
|
||||
chainExtra(dto.config ?? asConfig(existing.config)).chainMode,
|
||||
);
|
||||
}
|
||||
|
||||
const row = await this.prisma.automationRule.update({
|
||||
where: { id },
|
||||
@@ -120,7 +152,10 @@ export class AutomationsService {
|
||||
...(steps && nextAction
|
||||
? {
|
||||
action: nextAction,
|
||||
config: packAutomationConfig(steps) as Prisma.InputJsonValue,
|
||||
config: packAutomationConfig(
|
||||
steps,
|
||||
chainExtra(dto.config ?? asConfig(existing.config)),
|
||||
) as Prisma.InputJsonValue,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -147,21 +182,40 @@ export class AutomationsService {
|
||||
trigger,
|
||||
},
|
||||
});
|
||||
const facts = await this.loadFacts(ctx);
|
||||
if (!rules.length) return;
|
||||
const facts: AutomationFacts = {
|
||||
...(await this.loadFacts(ctx)),
|
||||
formValues: ctx.formValues ?? {},
|
||||
};
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
const config = asConfig(rule.config);
|
||||
if (
|
||||
config.sourceId &&
|
||||
ctx.sourceId &&
|
||||
config.sourceId !== ctx.sourceId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (config.sourceId && !ctx.sourceId) continue;
|
||||
const steps = normalizeAutomationSteps(
|
||||
rule.action as AutomationAction,
|
||||
config,
|
||||
);
|
||||
for (const step of steps) {
|
||||
if (isAutomationCondition(step)) {
|
||||
if (!matchCondition(step, facts)) break;
|
||||
continue;
|
||||
const actions = planAutomationActions(steps, config.chainMode, (step) =>
|
||||
matchCondition(step, facts),
|
||||
);
|
||||
for (const step of actions) {
|
||||
try {
|
||||
await this.execute(step.action!, automationStepToConfig(step), ctx, {
|
||||
ruleId: rule.id,
|
||||
stepId: step.id,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Automation ${rule.id} step ${step.id} failed: ${(e as Error)?.message ?? e}`,
|
||||
);
|
||||
}
|
||||
if (!step.action) continue;
|
||||
await this.execute(step.action, automationStepToConfig(step), ctx);
|
||||
}
|
||||
} catch {
|
||||
// قوانین نباید شکست عملیات اصلی را ایجاد کنند
|
||||
@@ -169,7 +223,9 @@ export class AutomationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFacts(ctx: AutomationContext): Promise<AutomationFacts> {
|
||||
private async loadFacts(
|
||||
ctx: AutomationContext,
|
||||
): Promise<Omit<AutomationFacts, 'formValues'>> {
|
||||
if (ctx.entityType === 'deal') {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: ctx.entityId, organizationId: ctx.organizationId },
|
||||
@@ -246,42 +302,26 @@ export class AutomationsService {
|
||||
|
||||
private async execute(
|
||||
action: AutomationAction,
|
||||
config: AutomationRuleConfig,
|
||||
rawConfig: AutomationRuleConfig,
|
||||
ctx: AutomationContext,
|
||||
ref: StepRef,
|
||||
) {
|
||||
const vars = {
|
||||
title: ctx.title,
|
||||
mobile: ctx.mobile,
|
||||
formName: ctx.formName,
|
||||
formValues: ctx.formValues,
|
||||
};
|
||||
const config: AutomationRuleConfig = {
|
||||
...rawConfig,
|
||||
title: rawConfig.title ? renderAutomationTemplate(rawConfig.title, vars) : rawConfig.title,
|
||||
body: rawConfig.body ? renderAutomationTemplate(rawConfig.body, vars) : rawConfig.body,
|
||||
};
|
||||
const title =
|
||||
(config.title?.trim() || defaultTitle(action, ctx.title)).slice(0, 200);
|
||||
|
||||
if (action === 'create_activity') {
|
||||
const assigneeId =
|
||||
config.assigneeId?.trim() ||
|
||||
ctx.assigneeId ||
|
||||
ctx.ownerId ||
|
||||
null;
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: ctx.organizationId,
|
||||
type: (config.activityType as never) || 'task',
|
||||
title,
|
||||
description: config.body?.trim() || null,
|
||||
personId: ctx.personId ?? null,
|
||||
companyId: ctx.companyId ?? null,
|
||||
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
|
||||
assigneeId,
|
||||
},
|
||||
});
|
||||
if (assigneeId) {
|
||||
await this.notifications.create({
|
||||
organizationId: ctx.organizationId,
|
||||
userId: assigneeId,
|
||||
type: 'automation',
|
||||
title: 'فعالیت خودکار',
|
||||
body: title,
|
||||
link: entityLink(ctx),
|
||||
entityType: ctx.entityType,
|
||||
entityId: ctx.entityId,
|
||||
});
|
||||
}
|
||||
await this.executeCreateActivity(title, config, ctx, ref);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -301,6 +341,180 @@ export class AutomationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async executeCreateActivity(
|
||||
title: string,
|
||||
config: AutomationRuleConfig,
|
||||
ctx: AutomationContext,
|
||||
ref: StepRef,
|
||||
) {
|
||||
const assigneeId = await this.resolveAssignee(config, ctx, ref);
|
||||
|
||||
let type = (config.activityType as 'call' | 'meeting' | 'task' | 'note') || 'task';
|
||||
let typeDefId: string | null = null;
|
||||
if (config.activityTypeDefId) {
|
||||
const def = await this.prisma.activityTypeDef.findFirst({
|
||||
where: { id: config.activityTypeDefId, organizationId: ctx.organizationId },
|
||||
select: { id: true, baseType: true },
|
||||
});
|
||||
if (def) {
|
||||
typeDefId = def.id;
|
||||
type = def.baseType as typeof type;
|
||||
}
|
||||
}
|
||||
|
||||
const offset = config.dueOffsetMinutes;
|
||||
const dueAt =
|
||||
typeof offset === 'number' && Number.isFinite(offset)
|
||||
? new Date(Date.now() + Math.max(0, offset) * 60_000)
|
||||
: null;
|
||||
|
||||
const description = [
|
||||
config.body?.trim(),
|
||||
leadSummary(ctx),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.slice(0, 5000);
|
||||
|
||||
const activity = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: ctx.organizationId,
|
||||
type,
|
||||
typeDefId,
|
||||
title,
|
||||
description: description || null,
|
||||
dueAt,
|
||||
personId: ctx.personId ?? null,
|
||||
companyId: ctx.companyId ?? null,
|
||||
dealId: ctx.dealId ?? (ctx.entityType === 'deal' ? ctx.entityId : null),
|
||||
leadId: ctx.leadId ?? (ctx.entityType === 'lead' ? ctx.entityId : null),
|
||||
assigneeId,
|
||||
assignees: assigneeId ? { connect: [{ id: assigneeId }] } : undefined,
|
||||
tags: ['automation'],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (assigneeId) {
|
||||
await this.notifications.create({
|
||||
organizationId: ctx.organizationId,
|
||||
userId: assigneeId,
|
||||
type: 'automation',
|
||||
title: 'فعالیت خودکار',
|
||||
body: title,
|
||||
link: `/activities/${activity.id}`,
|
||||
entityType: 'activity',
|
||||
entityId: activity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAssignee(
|
||||
config: AutomationRuleConfig,
|
||||
ctx: AutomationContext,
|
||||
ref: StepRef,
|
||||
): Promise<string | null> {
|
||||
const fallback = ctx.assigneeId || ctx.ownerId || null;
|
||||
const mode = config.assignMode ?? (config.assigneeId ? 'user' : 'owner');
|
||||
|
||||
if (mode === 'user') {
|
||||
const id = config.assigneeId?.trim();
|
||||
if (id && (await this.isMember(ctx.organizationId, id))) return id;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (mode === 'team_round_robin' || mode === 'users_round_robin') {
|
||||
let candidates: string[] = [];
|
||||
if (mode === 'team_round_robin' && config.assigneeTeamId) {
|
||||
const members = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId: config.assigneeTeamId,
|
||||
team: { organizationId: ctx.organizationId },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
candidates = members.map((m) => m.userId);
|
||||
} else if (mode === 'users_round_robin') {
|
||||
candidates = config.assigneeUserIds ?? [];
|
||||
}
|
||||
const valid = await this.filterMembers(ctx.organizationId, candidates);
|
||||
if (!valid.length) return fallback;
|
||||
const cursor = await this.prisma.automationCursor.upsert({
|
||||
where: { ruleId_stepId: { ruleId: ref.ruleId, stepId: ref.stepId } },
|
||||
create: { ruleId: ref.ruleId, stepId: ref.stepId, counter: 1 },
|
||||
update: { counter: { increment: 1 } },
|
||||
select: { counter: true },
|
||||
});
|
||||
return pickRoundRobin(valid, cursor.counter) ?? fallback;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private async isMember(organizationId: string, userId: string) {
|
||||
return (await this.filterMembers(organizationId, [userId])).length > 0;
|
||||
}
|
||||
|
||||
private async filterMembers(organizationId: string, userIds: string[]) {
|
||||
const ids = [...new Set(userIds.filter(Boolean))];
|
||||
if (!ids.length) return [];
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
memberships: { some: { organizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
private async assertStepRefs(organizationId: string, steps: AutomationStep[]) {
|
||||
for (const step of steps) {
|
||||
if (step.kind === 'condition' || step.action !== 'create_activity') continue;
|
||||
if (step.assignMode === 'team_round_robin') {
|
||||
if (!step.assigneeTeamId) {
|
||||
throw new BadRequestException('برای تقسیم نوبتی، تیم را انتخاب کنید');
|
||||
}
|
||||
const team = await this.prisma.team.findFirst({
|
||||
where: { id: step.assigneeTeamId, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!team) throw new BadRequestException('تیم انتخابشده معتبر نیست');
|
||||
}
|
||||
if (step.assignMode === 'users_round_robin') {
|
||||
const ids = step.assigneeUserIds ?? [];
|
||||
if (!ids.length) {
|
||||
throw new BadRequestException('برای تقسیم نوبتی، حداقل یک کاربر انتخاب کنید');
|
||||
}
|
||||
const valid = await this.filterMembers(organizationId, ids);
|
||||
if (valid.length !== new Set(ids).size) {
|
||||
throw new BadRequestException('یکی از کاربران انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
if (step.assignMode === 'user' && step.assigneeId) {
|
||||
if (!(await this.isMember(organizationId, step.assigneeId))) {
|
||||
throw new BadRequestException('مسئول انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLevelAllows(
|
||||
organizationId: string,
|
||||
steps: AutomationStep[],
|
||||
chainMode: AutomationChainMode | undefined,
|
||||
) {
|
||||
if (!automationUsesAdvanced(steps, chainMode)) return;
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { enabledModules: true },
|
||||
});
|
||||
if (!org?.enabledModules.includes('workflow_automations_advanced')) {
|
||||
throw new BadRequestException(
|
||||
'شاخهها، شرط روی پاسخ فرم و تقسیم نوبتی فقط در اتوماسیون حرفهای فعال است',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeNotify(
|
||||
title: string,
|
||||
config: AutomationRuleConfig,
|
||||
@@ -452,9 +666,37 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
|
||||
return current === value;
|
||||
}
|
||||
|
||||
if (field === 'form_field') {
|
||||
const key = step.conditionKey?.trim();
|
||||
if (!key) return false;
|
||||
return matchFormFieldCondition(op, facts.formValues[key], formConditionValues(step));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function chainExtra(
|
||||
config?: AutomationRuleConfig | null,
|
||||
): Pick<AutomationRuleConfig, 'sourceId' | 'chainMode'> {
|
||||
return {
|
||||
sourceId: config?.sourceId ?? null,
|
||||
chainMode: config?.chainMode === 'branches' ? 'branches' : config?.chainMode === 'stop' ? 'stop' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function leadSummary(ctx: AutomationContext): string {
|
||||
if (ctx.sourceType !== 'lead_form') return '';
|
||||
const head = [
|
||||
ctx.formName ? `فرم: ${ctx.formName}` : '',
|
||||
ctx.title ? `نام: ${ctx.title}` : '',
|
||||
ctx.mobile ? `موبایل: ${ctx.mobile}` : '',
|
||||
].filter(Boolean);
|
||||
const answers = formatFormAnswers(ctx.formFields, ctx.formValues);
|
||||
return [head.join('\n'), answers ? `پاسخهای فرم:\n${answers}` : '']
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function defaultTitle(action: AutomationAction, entityTitle: string) {
|
||||
if (action === 'create_activity') return `پیگیری خودکار: ${entityTitle}`;
|
||||
if (action === 'create_notification') return `اتوماسیون: ${entityTitle}`;
|
||||
@@ -467,5 +709,6 @@ function entityLink(ctx: AutomationContext) {
|
||||
if (ctx.entityType === 'invoice') return `/invoices/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'person') return `/contacts/people/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'activity') return `/activities/${ctx.entityId}`;
|
||||
if (ctx.entityType === 'lead') return `/leads/${ctx.entityId}`;
|
||||
return '/';
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const TRIGGERS = [
|
||||
'invoice_issued',
|
||||
'invoice_paid',
|
||||
'activity_done',
|
||||
'sms_link_clicked',
|
||||
] as const;
|
||||
const ACTIONS = [
|
||||
'create_activity',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { BillingService } from './billing.service';
|
||||
import { OrgModuleGuard } from './org-module.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [BillingService, OrgModuleGuard],
|
||||
exports: [BillingService, OrgModuleGuard],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
ApplyBillingPlanRequest,
|
||||
BillingAccountDto,
|
||||
BillingAccountStatus,
|
||||
BillingPlanCode,
|
||||
OrgModuleKey,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
BILLING_PLAN_DAYS,
|
||||
BILLING_PLAN_DEFAULT_MAX_ORGS,
|
||||
BILLING_TRIAL_DAYS,
|
||||
defaultEnabledModules,
|
||||
isPermanentBillingPlan,
|
||||
isOrgModuleKey,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||
const ms = expiresAt.getTime() - now.getTime();
|
||||
return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
export function toSummary(row: {
|
||||
planCode: string;
|
||||
status: string;
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
}): SubscriptionSummaryDto {
|
||||
const remaining = daysRemaining(row.expiresAt);
|
||||
const status = (row.status as BillingAccountStatus) ?? 'active';
|
||||
return {
|
||||
planCode: row.planCode as BillingPlanCode,
|
||||
status,
|
||||
startsAt: row.startsAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
maxOrganizations: row.maxOrganizations,
|
||||
daysRemaining: remaining,
|
||||
warn: !isPermanentBillingPlan(row.planCode) && remaining > 0 && remaining <= 3,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
toDto(row: {
|
||||
id: string;
|
||||
ownerUserId: string;
|
||||
planCode: string;
|
||||
status: string;
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
externalPaymentRef: string | null;
|
||||
notes: string | null;
|
||||
}): BillingAccountDto {
|
||||
const summary = toSummary(row);
|
||||
return {
|
||||
id: row.id,
|
||||
ownerUserId: row.ownerUserId,
|
||||
...summary,
|
||||
externalPaymentRef: row.externalPaymentRef,
|
||||
notes: row.notes,
|
||||
};
|
||||
}
|
||||
|
||||
async getByOwnerUserId(ownerUserId: string) {
|
||||
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||
}
|
||||
|
||||
async ensureTrialForOwner(
|
||||
ownerUserId: string,
|
||||
opts?: { maxOrganizations?: number },
|
||||
) {
|
||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||
if (existing) return existing;
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + BILLING_TRIAL_DAYS * 24 * 60 * 60 * 1000);
|
||||
return this.prisma.billingAccount.create({
|
||||
data: {
|
||||
ownerUserId,
|
||||
planCode: 'trial',
|
||||
status: 'active',
|
||||
startsAt: now,
|
||||
expiresAt,
|
||||
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** اشتراک محیط = BillingAccount اولین مالک فعال — برای عضو زیرمجموعه حساب جدید ساخته نمیشود */
|
||||
async resolveForOrganization(organizationId: string) {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
role: 'owner',
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!owner) return null;
|
||||
return this.getByOwnerUserId(owner.userId);
|
||||
}
|
||||
|
||||
async requireActiveForOrganization(organizationId: string): Promise<SubscriptionSummaryDto> {
|
||||
const account = await this.resolveForOrganization(organizationId);
|
||||
if (!account) {
|
||||
throw new UnauthorizedException(
|
||||
'اشتراک این محیط یافت نشد؛ مالک باید از پنل سوپرادمین پلن داشته باشد',
|
||||
);
|
||||
}
|
||||
return this.assertActive(account);
|
||||
}
|
||||
|
||||
async requireActiveForOwner(ownerUserId: string): Promise<SubscriptionSummaryDto> {
|
||||
const account = await this.getByOwnerUserId(ownerUserId);
|
||||
if (!account) {
|
||||
throw new UnauthorizedException(
|
||||
'اشتراک یافت نشد؛ ابتدا پلن را از سوپرادمین اعمال کنید',
|
||||
);
|
||||
}
|
||||
return this.assertActive(account);
|
||||
}
|
||||
|
||||
private async assertActive(account: {
|
||||
id: string;
|
||||
planCode: string;
|
||||
status: string;
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
}): Promise<SubscriptionSummaryDto> {
|
||||
const now = new Date();
|
||||
if (account.status === 'suspended') {
|
||||
throw new UnauthorizedException('اشتراک این حساب معلق شده است');
|
||||
}
|
||||
if (account.status === 'expired' || account.expiresAt.getTime() < now.getTime()) {
|
||||
if (account.status !== 'expired') {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||
}
|
||||
return toSummary(account);
|
||||
}
|
||||
|
||||
async assertCanCreateOrganization(ownerUserId: string) {
|
||||
let account = await this.getByOwnerUserId(ownerUserId);
|
||||
if (!account) {
|
||||
// اولین محیط مالک: trial خودکار
|
||||
account = await this.ensureTrialForOwner(ownerUserId, { maxOrganizations: 1 });
|
||||
}
|
||||
const summary = await this.assertActive(account);
|
||||
const ownedCount = await this.prisma.membership.count({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
});
|
||||
if (ownedCount >= summary.maxOrganizations) {
|
||||
throw new ForbiddenException(
|
||||
`سقف تعداد محیط برای این اشتراک ${summary.maxOrganizations} است`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async applyPlan(
|
||||
ownerUserId: string,
|
||||
dto: ApplyBillingPlanRequest,
|
||||
): Promise<BillingAccountDto> {
|
||||
const ownerMembership = await this.prisma.membership.findFirst({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!ownerMembership) {
|
||||
throw new BadRequestException(
|
||||
'اشتراک فقط برای مالک محیط تعریف میشود؛ این کاربر مالک هیچ محیطی نیست',
|
||||
);
|
||||
}
|
||||
|
||||
const planCode = dto.planCode;
|
||||
const now = new Date();
|
||||
let expiresAt: Date;
|
||||
let maxOrganizations: number;
|
||||
|
||||
if (planCode === 'custom') {
|
||||
if (!dto.expiresAt) {
|
||||
throw new BadRequestException('برای پلن سفارشی تاریخ انقضا لازم است');
|
||||
}
|
||||
expiresAt = new Date(dto.expiresAt);
|
||||
if (Number.isNaN(expiresAt.getTime())) {
|
||||
throw new BadRequestException('تاریخ انقضا معتبر نیست');
|
||||
}
|
||||
maxOrganizations = Math.max(1, dto.maxOrganizations ?? 1);
|
||||
} else {
|
||||
const days = BILLING_PLAN_DAYS[planCode];
|
||||
expiresAt = dto.expiresAt
|
||||
? new Date(dto.expiresAt)
|
||||
: new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
if (Number.isNaN(expiresAt.getTime())) {
|
||||
throw new BadRequestException('تاریخ انقضا معتبر نیست');
|
||||
}
|
||||
maxOrganizations = Math.max(
|
||||
1,
|
||||
dto.maxOrganizations ?? BILLING_PLAN_DEFAULT_MAX_ORGS[planCode],
|
||||
);
|
||||
}
|
||||
|
||||
const status: BillingAccountStatus =
|
||||
dto.status ??
|
||||
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||
|
||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||
const row = existing
|
||||
? await this.prisma.billingAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
planCode,
|
||||
status,
|
||||
startsAt: existing.startsAt ?? now,
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||
},
|
||||
})
|
||||
: await this.prisma.billingAccount.create({
|
||||
data: {
|
||||
ownerUserId,
|
||||
planCode,
|
||||
status,
|
||||
startsAt: now,
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
notes: dto.notes ?? null,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async setOrgModules(organizationId: string, modules: string[]) {
|
||||
const cleaned = [
|
||||
...new Set(
|
||||
modules.filter(isOrgModuleKey),
|
||||
),
|
||||
] as OrgModuleKey[];
|
||||
const prev = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { priorArchiveEnabledAt: true },
|
||||
});
|
||||
const enablePrior = cleaned.includes('prior_data_archive');
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: {
|
||||
enabledModules: cleaned,
|
||||
},
|
||||
});
|
||||
if (enablePrior && prev?.priorArchiveEnabledAt) {
|
||||
await this.backfillPriorActivities(organizationId, prev.priorArchiveEnabledAt);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** فعالیتهای منتقلشده را یکبار (و تکرار بیاثر) بهعنوان اطلاعات قبل علامت میزند. */
|
||||
private async backfillPriorActivities(organizationId: string, cutoff: Date) {
|
||||
await this.prisma.$executeRaw(Prisma.sql`
|
||||
UPDATE "Activity" AS a
|
||||
SET "importSource" = 'didar'
|
||||
WHERE a."organizationId" = ${organizationId}
|
||||
AND a."importSource" = 'manual'
|
||||
AND (
|
||||
(
|
||||
a."dealId" IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "Deal" AS d
|
||||
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
a."createdAt" < ${cutoff}
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM "Person" AS p
|
||||
WHERE p."id" = a."personId"
|
||||
AND (
|
||||
p."importSource" = 'didar'
|
||||
OR p."previousImportSource" = 'didar'
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "Company" AS c
|
||||
WHERE c."id" = a."companyId" AND c."importSource" = 'didar'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "Deal" AS d2
|
||||
WHERE d2."id" = a."dealId" AND d2."importSource" = 'didar'
|
||||
)
|
||||
OR (
|
||||
a."personId" IS NULL
|
||||
AND a."companyId" IS NULL
|
||||
AND a."dealId" IS NULL
|
||||
AND a."dueAt" IS NOT NULL
|
||||
AND a."dueAt" < ${cutoff}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
|
||||
if (!raw?.length) return defaultEnabledModules();
|
||||
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
|
||||
return cleaned.length ? cleaned : defaultEnabledModules();
|
||||
}
|
||||
|
||||
async requireModule(organizationId: string, module: OrgModuleKey) {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { enabledModules: true },
|
||||
});
|
||||
if (!org) throw new ForbiddenException('محیط پیدا نشد');
|
||||
const enabled = this.normalizeModules(org.enabledModules);
|
||||
if (!enabled.includes(module)) {
|
||||
throw new ForbiddenException('این ماژول برای این محیط فعال نیست');
|
||||
}
|
||||
}
|
||||
|
||||
defaultModulesForNewOrg(): OrgModuleKey[] {
|
||||
return defaultEnabledModules();
|
||||
}
|
||||
|
||||
async countExpiringSoon() {
|
||||
const now = new Date();
|
||||
const in3 = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||
return this.prisma.billingAccount.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
expiresAt: { gt: now, lte: in3 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async countExpired() {
|
||||
return this.prisma.billingAccount.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'expired' },
|
||||
{ status: 'active', expiresAt: { lt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { OrgModuleKey } from '@mento/shared';
|
||||
|
||||
export const ORG_MODULE_KEY = 'org_module';
|
||||
|
||||
export const RequireOrgModule = (module: OrgModuleKey) =>
|
||||
SetMetadata(ORG_MODULE_KEY, module);
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { OrgModuleKey } from '@mento/shared';
|
||||
import { BillingService } from './billing.service';
|
||||
import { ORG_MODULE_KEY } from './org-module.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class OrgModuleGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const module = this.reflector.getAllAndOverride<OrgModuleKey | undefined>(
|
||||
ORG_MODULE_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!module) return true;
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: { organizationId?: string };
|
||||
}>();
|
||||
const organizationId = req.user?.organizationId;
|
||||
if (!organizationId) return false;
|
||||
await this.billing.requireModule(organizationId, module);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,23 @@ export function toJsonCustomFields(
|
||||
): Prisma.InputJsonValue {
|
||||
return (value ?? {}) as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export function asAddresses(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
const text = String(item ?? '').trim();
|
||||
if (text && !out.includes(text)) out.push(text);
|
||||
if (out.length >= 20) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function toJsonAddresses(value?: string[] | null): Prisma.InputJsonValue {
|
||||
return asAddresses(value ?? []);
|
||||
}
|
||||
|
||||
/** Collect address-like cells from an excel row into a unique list. */
|
||||
export function collectAddresses(...parts: Array<string | undefined | null>): string[] {
|
||||
return asAddresses(parts.map((p) => (p ?? '').trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export const EXCEL_IMPORT_CHUNK_SIZE = 150;
|
||||
/** بعد از این تعداد ردیف، کلاینت حتماً چندمرحلهای میرود (پیشنمایش هم نشان میدهد) */
|
||||
export const EXCEL_IMPORT_LARGE_THRESHOLD = 80;
|
||||
const SESSION_TTL_MS = 45 * 60 * 1000;
|
||||
const MAX_SESSIONS = 40;
|
||||
const MAX_ASYNC_ROWS = 100_000;
|
||||
|
||||
export type ExcelImportLookupCaches = {
|
||||
user: Map<string, string | null>;
|
||||
person: Map<string, string | null>;
|
||||
company: Map<string, string | null>;
|
||||
deal: Map<string, string | null>;
|
||||
};
|
||||
|
||||
export type ExcelImportSessionStatus = {
|
||||
sessionId: string;
|
||||
status: 'parsing' | 'ready' | 'error';
|
||||
total: number;
|
||||
parsed: number;
|
||||
error?: string;
|
||||
hasMore?: boolean;
|
||||
nextOffset?: number;
|
||||
};
|
||||
|
||||
type ExcelImportSession = {
|
||||
organizationId: string;
|
||||
rows: Array<Record<string, string>>;
|
||||
expiresAt: number;
|
||||
caches: ExcelImportLookupCaches;
|
||||
status: 'parsing' | 'ready' | 'error';
|
||||
error?: string;
|
||||
total: number;
|
||||
parsed: number;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, ExcelImportSession>();
|
||||
const TMP_DIR = path.join(os.tmpdir(), 'mento-excel-import');
|
||||
|
||||
function emptyCaches(): ExcelImportLookupCaches {
|
||||
return {
|
||||
user: new Map(),
|
||||
person: new Map(),
|
||||
company: new Map(),
|
||||
deal: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function purgeExpired() {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (session.expiresAt <= now) {
|
||||
sessions.delete(id);
|
||||
cleanupSessionFiles(id);
|
||||
}
|
||||
}
|
||||
if (sessions.size <= MAX_SESSIONS) return;
|
||||
const ordered = [...sessions.entries()].sort(
|
||||
(a, b) => a[1].expiresAt - b[1].expiresAt,
|
||||
);
|
||||
while (sessions.size > MAX_SESSIONS && ordered.length) {
|
||||
const [id] = ordered.shift()!;
|
||||
sessions.delete(id);
|
||||
cleanupSessionFiles(id);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSessionFiles(sessionId: string) {
|
||||
for (const ext of ['.xlsx', '.jsonl']) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(TMP_DIR, `${sessionId}${ext}`));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function touch(session: ExcelImportSession) {
|
||||
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
||||
}
|
||||
|
||||
export function createExcelImportSession(
|
||||
organizationId: string,
|
||||
rows: Array<Record<string, string>>,
|
||||
): string {
|
||||
purgeExpired();
|
||||
const id = randomBytes(16).toString('hex');
|
||||
sessions.set(id, {
|
||||
organizationId,
|
||||
rows,
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
caches: emptyCaches(),
|
||||
status: 'ready',
|
||||
total: rows.length,
|
||||
parsed: rows.length,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/** فقط کش lookup برای ایمپورت JSON تکهای از مرورگر (بدون نگه داشتن کل فایل) */
|
||||
export function createExcelImportCacheSession(organizationId: string): string {
|
||||
purgeExpired();
|
||||
const id = randomBytes(16).toString('hex');
|
||||
sessions.set(id, {
|
||||
organizationId,
|
||||
rows: [],
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
caches: emptyCaches(),
|
||||
status: 'ready',
|
||||
total: 0,
|
||||
parsed: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* آپلود را فوری قبول میکند و پارس را در پسزمینه انجام میدهد
|
||||
* تا پروکسی nginx با ۵۰۲ قطع نکند.
|
||||
*/
|
||||
export function beginExcelImportPrepare(
|
||||
organizationId: string,
|
||||
buffer: Buffer,
|
||||
loadRows: (file: Express.Multer.File) => Promise<Array<Record<string, string>>>,
|
||||
): string {
|
||||
purgeExpired();
|
||||
fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||
const id = randomBytes(16).toString('hex');
|
||||
const xlsxPath = path.join(TMP_DIR, `${id}.xlsx`);
|
||||
fs.writeFileSync(xlsxPath, buffer);
|
||||
|
||||
sessions.set(id, {
|
||||
organizationId,
|
||||
rows: [],
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
caches: emptyCaches(),
|
||||
status: 'parsing',
|
||||
total: 0,
|
||||
parsed: 0,
|
||||
});
|
||||
|
||||
const fakeFile = {
|
||||
fieldname: 'file',
|
||||
originalname: 'import.xlsx',
|
||||
encoding: '7bit',
|
||||
mimetype:
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: xlsxPath,
|
||||
} as Express.Multer.File;
|
||||
|
||||
void (async () => {
|
||||
const session = sessions.get(id);
|
||||
if (!session) return;
|
||||
try {
|
||||
const rows = await loadRows(fakeFile);
|
||||
const current = sessions.get(id);
|
||||
if (!current) return;
|
||||
if (rows.length > MAX_ASYNC_ROWS) {
|
||||
current.status = 'error';
|
||||
current.error = `فایل بیش از ${MAX_ASYNC_ROWS} ردیف دارد. فایل را تقسیم کنید.`;
|
||||
current.total = rows.length;
|
||||
current.parsed = rows.length;
|
||||
touch(current);
|
||||
cleanupSessionFiles(id);
|
||||
return;
|
||||
}
|
||||
current.rows = rows;
|
||||
current.total = rows.length;
|
||||
current.parsed = rows.length;
|
||||
current.status = 'ready';
|
||||
touch(current);
|
||||
try {
|
||||
fs.unlinkSync(xlsxPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch (e) {
|
||||
const current = sessions.get(id);
|
||||
if (!current) return;
|
||||
current.status = 'error';
|
||||
current.error =
|
||||
e instanceof Error ? e.message : 'خواندن فایل اکسل ناموفق بود';
|
||||
touch(current);
|
||||
cleanupSessionFiles(id);
|
||||
}
|
||||
})();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getExcelImportPrepareStatus(
|
||||
sessionId: string,
|
||||
organizationId: string,
|
||||
): ExcelImportSessionStatus {
|
||||
purgeExpired();
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.organizationId !== organizationId) {
|
||||
throw new BadRequestException(
|
||||
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
touch(session);
|
||||
return {
|
||||
sessionId,
|
||||
status: session.status,
|
||||
total: session.total,
|
||||
parsed: session.parsed,
|
||||
error: session.error,
|
||||
hasMore: session.status === 'ready' ? session.total > 0 : true,
|
||||
nextOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExcelImportSession(
|
||||
sessionId: string,
|
||||
organizationId: string,
|
||||
): Array<Record<string, string>> {
|
||||
purgeExpired();
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.organizationId !== organizationId) {
|
||||
throw new BadRequestException(
|
||||
'جلسه ورود اکسل منقضی یا نامعتبر است. فایل را دوباره انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
if (session.status === 'parsing') {
|
||||
throw new BadRequestException(
|
||||
'فایل هنوز در حال آمادهسازی است. چند لحظه صبر کنید.',
|
||||
);
|
||||
}
|
||||
if (session.status === 'error') {
|
||||
throw new BadRequestException(
|
||||
session.error || 'آمادهسازی فایل اکسل ناموفق بود.',
|
||||
);
|
||||
}
|
||||
touch(session);
|
||||
return session.rows;
|
||||
}
|
||||
|
||||
export function destroyExcelImportSession(sessionId: string | undefined) {
|
||||
if (!sessionId) return;
|
||||
sessions.delete(sessionId);
|
||||
cleanupSessionFiles(sessionId);
|
||||
}
|
||||
|
||||
export function getExcelImportSessionCaches(
|
||||
sessionId: string,
|
||||
organizationId: string,
|
||||
): ExcelImportLookupCaches {
|
||||
purgeExpired();
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.organizationId !== organizationId) {
|
||||
return emptyCaches();
|
||||
}
|
||||
touch(session);
|
||||
return session.caches;
|
||||
}
|
||||
|
||||
export function parseChunkParams(raw: {
|
||||
offset?: string | number;
|
||||
limit?: string | number;
|
||||
}): { offset: number; limit: number } {
|
||||
const offset = Math.max(0, Number(raw.offset ?? 0) || 0);
|
||||
const limit = Math.min(
|
||||
250,
|
||||
Math.max(1, Number(raw.limit ?? EXCEL_IMPORT_CHUNK_SIZE) || EXCEL_IMPORT_CHUNK_SIZE),
|
||||
);
|
||||
return { offset, limit };
|
||||
}
|
||||
|
||||
export type ExcelImportChunkContext = {
|
||||
sessionId: string;
|
||||
allRows: Array<Record<string, string>>;
|
||||
slice: Array<Record<string, string>>;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* فایل را یکبار میخواند و در حافظه نگه میدارد؛ درخواستهای بعدی فقط با sessionId + offset.
|
||||
*/
|
||||
export async function resolveExcelImportChunk(opts: {
|
||||
organizationId: string;
|
||||
file?: Express.Multer.File | null;
|
||||
sessionId?: string;
|
||||
offset?: string | number;
|
||||
limit?: string | number;
|
||||
loadRows: () => Promise<Array<Record<string, string>>>;
|
||||
}): Promise<ExcelImportChunkContext> {
|
||||
const { offset, limit } = parseChunkParams({
|
||||
offset: opts.offset,
|
||||
limit: opts.limit,
|
||||
});
|
||||
|
||||
let sessionId = (opts.sessionId ?? '').trim();
|
||||
let allRows: Array<Record<string, string>>;
|
||||
|
||||
if (sessionId) {
|
||||
allRows = getExcelImportSession(sessionId, opts.organizationId);
|
||||
} else {
|
||||
if (!opts.file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
allRows = await opts.loadRows();
|
||||
sessionId = createExcelImportSession(opts.organizationId, allRows);
|
||||
}
|
||||
|
||||
const slice = allRows.slice(offset, offset + limit);
|
||||
const nextOffset = offset + slice.length;
|
||||
const hasMore = nextOffset < allRows.length;
|
||||
if (!hasMore) destroyExcelImportSession(sessionId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
allRows,
|
||||
slice,
|
||||
offset,
|
||||
nextOffset,
|
||||
hasMore,
|
||||
total: allRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function chunkImportMeta(ctx: ExcelImportChunkContext) {
|
||||
return {
|
||||
sessionId: ctx.hasMore ? ctx.sessionId : undefined,
|
||||
total: ctx.total,
|
||||
processed: ctx.nextOffset,
|
||||
hasMore: ctx.hasMore,
|
||||
nextOffset: ctx.hasMore ? ctx.nextOffset : undefined,
|
||||
};
|
||||
}
|
||||
+254
-27
@@ -1,24 +1,39 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { EXCEL_IMPORT_LARGE_THRESHOLD } from './excel-import-session';
|
||||
|
||||
export function cellStr(value: ExcelJS.CellValue | undefined): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value).trim();
|
||||
try {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
|
||||
}
|
||||
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part?.text ?? '').join('').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) {
|
||||
const result = value.result;
|
||||
if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') {
|
||||
return String(result).trim();
|
||||
}
|
||||
if (result instanceof Date) {
|
||||
return Number.isNaN(result.getTime()) ? '' : result.toISOString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && 'error' in value && value.error != null) {
|
||||
return String(value.error).trim();
|
||||
}
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('').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();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
export function normalizeHeader(raw: string) {
|
||||
@@ -154,6 +169,28 @@ export function parseAmount(raw?: string): number | null {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابهجا نشود. */
|
||||
export function parseBirthDate(raw?: string | null): Date | null {
|
||||
const text = toAsciiDigits(String(raw ?? '')).trim();
|
||||
if (!text || text === '-' || text === '0' || text === '0000-00-00') return null;
|
||||
const datePart = text.split(/[T\s]/)[0] ?? text;
|
||||
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
|
||||
if (!match) {
|
||||
const fallback = new Date(text);
|
||||
return Number.isNaN(fallback.getTime()) ? null : fallback;
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
if (year >= 1700) {
|
||||
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
||||
}
|
||||
if (year < 1200) return null;
|
||||
const { gy, gm, gd } = toGregorian(year, month, day);
|
||||
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
|
||||
}
|
||||
|
||||
export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null {
|
||||
const dateText = toAsciiDigits(dateRaw ?? '').trim();
|
||||
if (!dateText) return null;
|
||||
@@ -182,35 +219,225 @@ export async function workbookFromUpload(file: Express.Multer.File | undefined)
|
||||
throw new BadRequestException('فایل اکسل انتخاب نشده است');
|
||||
}
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
||||
try {
|
||||
// Pass Node Buffer directly — Uint8Array copies can break ExcelJS on some runtimes.
|
||||
await wb.xlsx.load(file.buffer as unknown as ExcelJS.Buffer);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : '';
|
||||
throw new BadRequestException(
|
||||
detail.includes('central directory') || detail.includes('zip')
|
||||
? 'فایل اکسل نامعتبر است. فقط فرمت .xlsx پشتیبانی میشود (نه .xls یا csv).'
|
||||
: 'خواندن فایل اکسل ناموفق بود. فایل را با فرمت .xlsx ذخیره کنید و دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
const sheet = wb.worksheets[0];
|
||||
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export type ExcelFieldOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type ExcelColumnPreview = {
|
||||
header: string;
|
||||
index: number;
|
||||
samples: string[];
|
||||
};
|
||||
|
||||
export type ExcelPreviewResult = {
|
||||
columns: ExcelColumnPreview[];
|
||||
fields: ExcelFieldOption[];
|
||||
suggested: Record<string, string>;
|
||||
needsMapping: boolean;
|
||||
rowCount: number;
|
||||
largeFile?: boolean;
|
||||
};
|
||||
|
||||
export function excelFieldsFromColumns(
|
||||
columns: Array<{ header: string; key: string }>,
|
||||
requiredKeys: string[] = [],
|
||||
): ExcelFieldOption[] {
|
||||
const seen = new Set<string>();
|
||||
const fields: ExcelFieldOption[] = [];
|
||||
for (const col of columns) {
|
||||
if (!col.key || col.key === '_type' || seen.has(col.key)) continue;
|
||||
seen.add(col.key);
|
||||
fields.push({
|
||||
key: col.key,
|
||||
label: col.header,
|
||||
required: requiredKeys.includes(col.key),
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseExcelMapping(raw?: string): Record<string, string> | undefined {
|
||||
if (raw == null || !String(raw).trim()) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw)) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const [header, field] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof field === 'string' && field.trim()) mapping[header] = field.trim();
|
||||
}
|
||||
return mapping;
|
||||
} catch {
|
||||
throw new BadRequestException('نگاشت ستونها نامعتبر است');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFieldKey(
|
||||
raw: string,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
) {
|
||||
if (mapping) {
|
||||
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
|
||||
}
|
||||
const compact = normalizeHeader(raw);
|
||||
return aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)] ?? '';
|
||||
}
|
||||
|
||||
export type ExcelPositionalColumn = { key: string; label: string };
|
||||
|
||||
export type ExcelReadOptions = {
|
||||
/** اگر ردیف اول هدر نباشد، از این ترتیب ستون استفاده شود */
|
||||
positionalColumns?: ExcelPositionalColumn[];
|
||||
/** تشخیص فایل بدون هدر (مثل خروجی دیدار) */
|
||||
detectHeaderless?: (firstRowCells: string[]) => boolean;
|
||||
};
|
||||
|
||||
function readFirstRowCells(sheet: ExcelJS.Worksheet): string[] {
|
||||
const cells: string[] = [];
|
||||
const row = sheet.getRow(1);
|
||||
const max = Math.max(row.cellCount || 0, sheet.columnCount || 0, 22);
|
||||
for (let col = 1; col <= max; col += 1) {
|
||||
cells.push(cellStr(row.getCell(col).value));
|
||||
}
|
||||
while (cells.length && !cells[cells.length - 1]) cells.pop();
|
||||
return cells;
|
||||
}
|
||||
|
||||
export async function previewExcel(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
fields: ExcelFieldOption[],
|
||||
options?: ExcelReadOptions,
|
||||
): Promise<ExcelPreviewResult> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const firstCells = readFirstRowCells(sheet);
|
||||
const headerless =
|
||||
Boolean(options?.positionalColumns?.length) &&
|
||||
Boolean(options?.detectHeaderless?.(firstCells));
|
||||
|
||||
const columns: ExcelColumnPreview[] = [];
|
||||
const suggested: Record<string, string> = {};
|
||||
const used = new Set<string>();
|
||||
|
||||
if (headerless && options?.positionalColumns) {
|
||||
const last = Math.min(sheet.rowCount, 5);
|
||||
options.positionalColumns.forEach((colDef, idx) => {
|
||||
const col = idx + 1;
|
||||
const samples: string[] = [];
|
||||
for (let r = 1; r <= last; r += 1) {
|
||||
const value = cellStr(sheet.getRow(r).getCell(col).value);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header: colDef.label, index: col, samples });
|
||||
suggested[colDef.label] = colDef.key;
|
||||
used.add(colDef.key);
|
||||
});
|
||||
} else {
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const header = cellStr(cell.value);
|
||||
if (!header) return;
|
||||
const samples: string[] = [];
|
||||
const last = Math.min(sheet.rowCount, 6);
|
||||
for (let r = 2; r <= last; r += 1) {
|
||||
const value = cellStr(sheet.getRow(r).getCell(col).value);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header, index: col, samples });
|
||||
const key = resolveFieldKey(header, aliases);
|
||||
if (key && !used.has(key)) {
|
||||
suggested[header] = key;
|
||||
used.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!columns.length) {
|
||||
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
|
||||
}
|
||||
|
||||
const required = fields.filter((f) => f.required).map((f) => f.key);
|
||||
const mapped = new Set(Object.values(suggested));
|
||||
const missingRequired = required.some((key) => !mapped.has(key));
|
||||
const unrecognized = columns.some((col) => !suggested[col.header]);
|
||||
|
||||
const rawRows = Math.max(0, sheet.actualRowCount || sheet.rowCount || 0);
|
||||
const rowCount = headerless ? rawRows : Math.max(0, rawRows - 1);
|
||||
|
||||
return {
|
||||
columns,
|
||||
fields,
|
||||
suggested,
|
||||
needsMapping: headerless
|
||||
? false
|
||||
: unrecognized || missingRequired || !mapped.size,
|
||||
rowCount,
|
||||
largeFile: rowCount >= EXCEL_IMPORT_LARGE_THRESHOLD,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMappedRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
options?: ExcelReadOptions,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
const firstCells = readFirstRowCells(sheet);
|
||||
const headerless =
|
||||
!mapping &&
|
||||
Boolean(options?.positionalColumns?.length) &&
|
||||
Boolean(options?.detectHeaderless?.(firstCells));
|
||||
|
||||
const map = new Map<number, string>();
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const compact = normalizeHeader(raw);
|
||||
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
let dataStartRow = 2;
|
||||
|
||||
if (headerless && options?.positionalColumns) {
|
||||
options.positionalColumns.forEach((colDef, idx) => {
|
||||
map.set(idx + 1, colDef.key);
|
||||
});
|
||||
dataStartRow = 1;
|
||||
} else {
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const key = resolveFieldKey(raw, aliases, mapping);
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
|
||||
'ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.',
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Record<string, string>[] = [];
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
if (rowNumber < dataStartRow) return;
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const [col, key] of map) {
|
||||
|
||||
@@ -103,6 +103,121 @@ export function jalaliMonthKey(d: Date): string {
|
||||
return `${jy}-${String(jm).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
|
||||
function toAsciiDigits(value: string) {
|
||||
return value
|
||||
.replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||
}
|
||||
|
||||
function toFaDigits(value: string | number) {
|
||||
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
|
||||
}
|
||||
|
||||
function tehranYmd(d: Date) {
|
||||
const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(d)
|
||||
.split('-')
|
||||
.map(Number);
|
||||
return { gy, gm, gd };
|
||||
}
|
||||
|
||||
/** تاریخ شمسی تهران، مثل ۱۴۰۲/۰۶/۱۲ */
|
||||
export function formatTehranJalali(d: Date): string {
|
||||
const { gy, gm, gd } = tehranYmd(d);
|
||||
const j = toJalali(gy, gm, gd);
|
||||
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
|
||||
}
|
||||
|
||||
const GREG_MONTHS: Array<[string, number]> = [
|
||||
['ژانویه', 1],
|
||||
['january', 1],
|
||||
['فوریه', 2],
|
||||
['february', 2],
|
||||
['مارس', 3],
|
||||
['march', 3],
|
||||
['آوریل', 4],
|
||||
['april', 4],
|
||||
['ژوئن', 6],
|
||||
['june', 6],
|
||||
['ژوئیه', 7],
|
||||
['جولای', 7],
|
||||
['july', 7],
|
||||
['اوت', 8],
|
||||
['آگوست', 8],
|
||||
['آگست', 8],
|
||||
['august', 8],
|
||||
['سپتامبر', 9],
|
||||
['september', 9],
|
||||
['اکتبر', 10],
|
||||
['october', 10],
|
||||
['نوامبر', 11],
|
||||
['november', 11],
|
||||
['دسامبر', 12],
|
||||
['december', 12],
|
||||
['مه', 5],
|
||||
['می', 5],
|
||||
['may', 5],
|
||||
];
|
||||
|
||||
function gregMonthNumber(name: string): number | null {
|
||||
const key = name.trim().toLowerCase();
|
||||
return GREG_MONTHS.find(([label]) => label === key)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function inferGregorianYear(month: number, day: number, now = new Date()): number {
|
||||
const { gy, gm, gd } = tehranYmd(now);
|
||||
if (month > gm || (month === gm && day > gd)) return gy - 1;
|
||||
return gy;
|
||||
}
|
||||
|
||||
function jalaliFromGregorian(year: number, month: number, day: number): string | null {
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1700 || year > 2500) return null;
|
||||
const j = toJalali(year, month, day);
|
||||
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
|
||||
}
|
||||
|
||||
/** «21 سپتامبر» و تاریخ میلادی عددی را در متن کاربر به شمسی تبدیل میکند. */
|
||||
export function rewriteGregorianDates(text: string, now = new Date()): string {
|
||||
const monthAlt = GREG_MONTHS.map(([name]) => name).join('|');
|
||||
const dayMonth = new RegExp(
|
||||
`(^|[^\\p{L}\\d])([0-9۰-۹]{1,2})\\s*(${monthAlt})(?![\\p{L}\\u200c])(?:\\s+([0-9۰-۹]{4}))?`,
|
||||
'giu',
|
||||
);
|
||||
const monthDay = new RegExp(
|
||||
`(^|[^\\p{L}\\d])(${monthAlt})(?![\\p{L}\\u200c])\\s+([0-9۰-۹]{1,2})(?:\\s*,?\\s*([0-9۰-۹]{4}))?`,
|
||||
'giu',
|
||||
);
|
||||
const iso = /\b((?:19|20)\d{2})-(\d{2})-(\d{2})\b/g;
|
||||
|
||||
const fromParts = (dayRaw: string, monthName: string, yearRaw?: string) => {
|
||||
const day = Number(toAsciiDigits(dayRaw));
|
||||
const month = gregMonthNumber(monthName);
|
||||
if (!month) return null;
|
||||
const year = yearRaw ? Number(toAsciiDigits(yearRaw)) : inferGregorianYear(month, day, now);
|
||||
return jalaliFromGregorian(year, month, day);
|
||||
};
|
||||
|
||||
let out = text.replace(dayMonth, (full, lead: string, day: string, month: string, year?: string) => {
|
||||
const next = fromParts(day, month, year);
|
||||
return next ? `${lead}${next}` : full;
|
||||
});
|
||||
out = out.replace(monthDay, (full, lead: string, month: string, day: string, year?: string) => {
|
||||
const next = fromParts(day, month, year);
|
||||
return next ? `${lead}${next}` : full;
|
||||
});
|
||||
out = out.replace(iso, (full, year: string, month: string, day: string) => {
|
||||
return jalaliFromGregorian(Number(year), Number(month), Number(day)) ?? full;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function startOfJalaliMonth(jy: number, jm: number): Date {
|
||||
return jalaliToDate(jy, jm, 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { Actor } from './record-scope';
|
||||
|
||||
export type PriorLens = 'off' | 'current' | 'archive';
|
||||
|
||||
export function actorPriorLens(actor: Pick<Actor, 'priorLens'> | undefined): PriorLens {
|
||||
return actor?.priorLens ?? 'off';
|
||||
}
|
||||
|
||||
function hasCutoff(cutoff?: Date | null): cutoff is Date {
|
||||
return cutoff instanceof Date && !Number.isNaN(cutoff.getTime());
|
||||
}
|
||||
|
||||
/** شخص/شرکت دیگر با این لنز فیلتر نمیشوند. */
|
||||
export function personPriorWhere(_lens?: PriorLens): Prisma.PersonWhereInput {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function companyPriorWhere(_lens?: PriorLens): Prisma.CompanyWhereInput {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function dealPriorWhere(
|
||||
lens: PriorLens | undefined,
|
||||
cutoff?: Date | null,
|
||||
): Prisma.DealWhereInput {
|
||||
if (lens !== 'current' && lens !== 'archive') return {};
|
||||
if (!hasCutoff(cutoff)) return {};
|
||||
if (lens === 'current') {
|
||||
return {
|
||||
OR: [
|
||||
{ createdAt: { gte: cutoff } },
|
||||
{ promotedFromPriorAt: { not: null } },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
|
||||
}
|
||||
|
||||
export function activityPriorWhere(
|
||||
lens: PriorLens | undefined,
|
||||
cutoff?: Date | null,
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (lens !== 'current' && lens !== 'archive') return {};
|
||||
if (!hasCutoff(cutoff)) return {};
|
||||
if (lens === 'current') {
|
||||
return {
|
||||
OR: [
|
||||
{ createdAt: { gte: cutoff } },
|
||||
{ promotedFromPriorAt: { not: null } },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { createdAt: { lt: cutoff }, promotedFromPriorAt: null };
|
||||
}
|
||||
|
||||
/** فهرست شخص/شرکت: همیشه بدون فیلتر لنز (سازگاری با call-siteهای قدیمی). */
|
||||
export function listPriorLens(
|
||||
_lens: PriorLens | undefined,
|
||||
_q: string | undefined,
|
||||
_explicitSource: boolean,
|
||||
): PriorLens {
|
||||
return 'off';
|
||||
}
|
||||
@@ -1,44 +1,184 @@
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import { canViewAllRecords } from '@mento/shared';
|
||||
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
export type ScopeVisibility = {
|
||||
dataVisibility: DataVisibility;
|
||||
visibleUserIds: string[] | null;
|
||||
};
|
||||
|
||||
export type Actor = {
|
||||
userId: string;
|
||||
role: string;
|
||||
organizationId: string;
|
||||
/** own | team | org — سطح پیشفرض از Membership */
|
||||
dataVisibility?: DataVisibility;
|
||||
/**
|
||||
* شناسه کاربرانی که پروندهشان برای این Actor قابلمشاهده است.
|
||||
* اگر undefined باشد فقط خود کاربر؛ اگر null یعنی همه (elevated/org).
|
||||
*/
|
||||
visibleUserIds?: string[] | null;
|
||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
||||
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
||||
priorLens?: 'off' | 'current' | 'archive';
|
||||
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
||||
priorCutoff?: Date | null;
|
||||
/** مالک واقعی وقتی در حالت مشاهده بهجای کاربر دیگر است */
|
||||
impersonatorUserId?: string;
|
||||
impersonatorRole?: string;
|
||||
/** در حالت مشاهده، سرور تغییر را رد میکند */
|
||||
viewAsReadOnly?: boolean;
|
||||
};
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
|
||||
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
|
||||
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
|
||||
const s = actor.scopes?.[scope];
|
||||
if (!s) return actor;
|
||||
return { ...actor, dataVisibility: s.dataVisibility, visibleUserIds: s.visibleUserIds };
|
||||
}
|
||||
|
||||
export function parseVisibilityScopes(raw: unknown): DataVisibilityScopes {
|
||||
const out: DataVisibilityScopes = {};
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
for (const scope of DATA_SCOPES) {
|
||||
const v = (raw as Record<string, unknown>)[scope];
|
||||
if (v === 'own' || v === 'team' || v === 'org') out[scope] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function elevated(role: string | null | undefined): boolean {
|
||||
return canViewAllRecords(role);
|
||||
}
|
||||
|
||||
/** فیلتر مالکیت برای Deal / Quote / Invoice / Person / Company */
|
||||
export function seesAllRecords(
|
||||
actor: Pick<Actor, 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>,
|
||||
scope?: DataScope,
|
||||
): boolean {
|
||||
if (elevated(actor.role)) return true;
|
||||
const s = scope ? actor.scopes?.[scope] : undefined;
|
||||
const visibility = s ? s.dataVisibility : actor.dataVisibility;
|
||||
const userIds = s ? s.visibleUserIds : actor.visibleUserIds;
|
||||
if (visibility === 'org') return true;
|
||||
if (userIds === null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** شناسه کاربرانی که پروندهشان در این بخش دیده میشود؛ null یعنی همه. */
|
||||
export function scopedOwnerIds(
|
||||
actor: ScopedActor,
|
||||
scope?: DataScope,
|
||||
): string[] | null {
|
||||
const a = scope ? forScope(actor, scope) : actor;
|
||||
if (seesAllRecords(a)) return null;
|
||||
const ids = a.visibleUserIds?.length
|
||||
? a.visibleUserIds
|
||||
: [a.userId];
|
||||
return [...new Set(ids.filter(Boolean))];
|
||||
}
|
||||
|
||||
/** فیلتر مالکیت؛ پیشفرض بخش «مشتریان» (Person / Company). برای فاکتور/پیشفاکتور scope=finance. */
|
||||
export function ownerWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
): { ownerId: string } | Record<string, never> {
|
||||
if (elevated(actor.role)) return {};
|
||||
return { ownerId: actor.userId };
|
||||
actor: ScopedActor,
|
||||
scope: DataScope = 'contacts',
|
||||
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
|
||||
const ids = scopedOwnerIds(actor, scope);
|
||||
if (!ids) return {};
|
||||
if (ids.length === 1) return { ownerId: ids[0]! };
|
||||
return { ownerId: { in: ids } };
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول */
|
||||
export function assigneeWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
): { assigneeId: string } | Record<string, never> {
|
||||
if (elevated(actor.role)) return {};
|
||||
return { assigneeId: actor.userId };
|
||||
}
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت */
|
||||
export function noteAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
): Prisma.NoteWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
|
||||
export function dealAccessWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.DealWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'deals');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ authorId: actor.userId },
|
||||
{ deal: { ownerId: actor.userId } },
|
||||
{ person: { ownerId: actor.userId } },
|
||||
{ company: { ownerId: actor.userId } },
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
|
||||
export function projectCardAccessWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ProjectCardWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'projects');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||
export function assigneeWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** دسترسی به فعالیت: مسئولان یا شرکتکنندگان (فیلد شرکتکنندگان) در محدوده دید */
|
||||
export function activityAccessWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||
export function noteAccessWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.NoteWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'notes');
|
||||
const personalGate: Prisma.NoteWhereInput = {
|
||||
OR: [
|
||||
{ visibility: { not: 'personal' } },
|
||||
{ visibility: 'personal', authorId: actor.userId },
|
||||
// رکوردهای قدیمی بدون فیلد visibility در DB با default team میآیند
|
||||
],
|
||||
};
|
||||
if (!ids) return personalGate;
|
||||
return {
|
||||
AND: [
|
||||
personalGate,
|
||||
{
|
||||
OR: [
|
||||
{ authorId: { in: ids } },
|
||||
{
|
||||
deal: {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ person: { ownerId: { in: ids } } },
|
||||
{ company: { ownerId: { in: ids } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -53,13 +193,14 @@ export function resolveOwnerId(
|
||||
return actor.userId;
|
||||
}
|
||||
|
||||
/** تیکت: مسئول یا ثبتکننده */
|
||||
/** تیکت: مسئول یا ثبتکننده در محدوده دید */
|
||||
export function ticketAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
actor: ScopedActor,
|
||||
): Prisma.TicketWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const ids = scopedOwnerIds(actor, 'tickets');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [{ assigneeId: actor.userId }, { reporterId: actor.userId }],
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,3 +209,10 @@ export function canReassignOwner(role: string | null | undefined): boolean {
|
||||
}
|
||||
|
||||
export type OrgRole = UserRole;
|
||||
|
||||
export function normalizeDataVisibility(
|
||||
value: string | null | undefined,
|
||||
): DataVisibility {
|
||||
if (value === 'team' || value === 'org' || value === 'own') return value;
|
||||
return 'own';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* الگوریتم مشترک جستجو در کل API:
|
||||
* - عبارت به کلمات شکسته میشود و همه کلمات باید (در هر فیلدی) پیدا شوند؛
|
||||
* پس «علی رضا» هم «علی رضایی» را پیدا میکند و بخشی از نام هم کافی است.
|
||||
* - حروف عربی/فارسی (ي/ی، ك/ک، ة/ه، أ/ا …) و ارقام فارسی/عربی/لاتین یکسان در نظر گرفته میشوند.
|
||||
* - برای شماره تلفن، پیشوند 0 / 98 / +98 نادیده گرفته میشود.
|
||||
*/
|
||||
|
||||
const MAX_TOKENS = 6;
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
function toAsciiDigits(s: string): string {
|
||||
return s.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const fa = FA_DIGITS.indexOf(ch);
|
||||
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
|
||||
});
|
||||
}
|
||||
|
||||
function toFaDigits(s: string): string {
|
||||
return s.replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]!);
|
||||
}
|
||||
|
||||
function toPersianLetters(s: string): string {
|
||||
return s
|
||||
.replace(/[يىئ]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/ة/g, 'ه')
|
||||
.replace(/[أإٱ]/g, 'ا')
|
||||
.replace(/ؤ/g, 'و');
|
||||
}
|
||||
|
||||
function toArabicLetters(s: string): string {
|
||||
return s.replace(/ی/g, 'ي').replace(/ک/g, 'ك');
|
||||
}
|
||||
|
||||
/** متن ورودی جستجو را یکدست میکند (ارقام لاتین، حروف فارسی، فاصلههای یکدست). */
|
||||
export function normalizeSearchText(raw: string | null | undefined): string {
|
||||
return toPersianLetters(toAsciiDigits(String(raw ?? '')))
|
||||
.replace(/[\u064B-\u065F\u0670]/g, '')
|
||||
.replace(/[\u200c\u200f\u200e]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function searchTokens(raw: string | null | undefined): string[] {
|
||||
const text = normalizeSearchText(raw);
|
||||
if (!text) return [];
|
||||
return [...new Set(text.split(' ').filter(Boolean))].slice(0, MAX_TOKENS);
|
||||
}
|
||||
|
||||
/** شکلهای مختلف یک کلمه که ممکن است در دیتابیس ذخیره شده باشد. */
|
||||
export function tokenVariants(token: string): string[] {
|
||||
const out = new Set<string>([token, toArabicLetters(token)]);
|
||||
if (/[0-9]/.test(token)) {
|
||||
out.add(toFaDigits(token));
|
||||
if (/^\+?[0-9]+$/.test(token)) {
|
||||
const core = token.replace(/^\+/, '').replace(/^(0098|98|0)/, '');
|
||||
if (core.length >= 3) out.add(core);
|
||||
}
|
||||
}
|
||||
return [...out].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* شرط Prisma برای جستجو: AND روی کلمات، OR روی شکلها و فیلدها.
|
||||
* `fieldsFor(term)` شرطهای OR یک کلمه را برای فیلدهای آن مدل برمیگرداند.
|
||||
*/
|
||||
export function buildSearchWhere<W extends object>(
|
||||
raw: string | null | undefined,
|
||||
fieldsFor: (term: string) => W[],
|
||||
): W | undefined {
|
||||
const tokens = searchTokens(raw);
|
||||
if (!tokens.length) return undefined;
|
||||
const parts = tokens.map((token) => ({
|
||||
OR: tokenVariants(token).flatMap((v) => fieldsFor(v)),
|
||||
}));
|
||||
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
||||
}
|
||||
|
||||
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
||||
export function ci(term: string) {
|
||||
return { contains: term, mode: 'insensitive' as const };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { jalaliMonthName, toJalali } from './jalali';
|
||||
import { applyMessageTemplate } from '../message-automations/message-automation.util';
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
|
||||
function toFaDigits(value: string | number) {
|
||||
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
|
||||
}
|
||||
|
||||
function tehranJalaliLong(d: Date) {
|
||||
const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(d)
|
||||
.split('-')
|
||||
.map(Number);
|
||||
const j = toJalali(gy, gm, gd);
|
||||
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
|
||||
}
|
||||
|
||||
export function renderSmsText(
|
||||
text: string,
|
||||
vars: { name?: string; phone?: string; title?: string; company?: string },
|
||||
) {
|
||||
return applyMessageTemplate(text, {
|
||||
name: vars.name,
|
||||
phone: vars.phone,
|
||||
title: vars.title,
|
||||
company: vars.company,
|
||||
date: tehranJalaliLong(new Date()),
|
||||
}).trim();
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
/** پنل ترِز / رایگان — همان الگوی montime */
|
||||
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
|
||||
@@ -44,8 +43,8 @@ export class SmsService {
|
||||
|
||||
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
||||
const template =
|
||||
this.config.get<string>('SMS_OTP_TEMPLATE') ??
|
||||
`کد ورود پرتال ${brand.nameFa}: {code}`;
|
||||
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
|
||||
`کد ورود منتکت: {code}`;
|
||||
const text = template.replace('{code}', code);
|
||||
const result = await this.sendSmsDetailed(mobile, text);
|
||||
return result.ok;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Normalize free-form tag lists (max 40 unique non-empty tags). */
|
||||
export function normalizeTags(input?: string[] | null): string[] {
|
||||
if (!input?.length) return [];
|
||||
const cleaned = input
|
||||
.map((t) => String(t ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => (t.startsWith('#') ? t.slice(1).trim() : t))
|
||||
.filter(Boolean);
|
||||
return [...new Set(cleaned)].slice(0, 40);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { jalaliMonthName, toJalali } from '../common/jalali';
|
||||
|
||||
export const BIRTHDAY_SMS_DEFAULT = '{نام} عزیز، تولدتان مبارک.';
|
||||
|
||||
export type BirthdayRow = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
mobile: string | null;
|
||||
birthDate: Date;
|
||||
};
|
||||
|
||||
export type BirthdayPick = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
mobile: string | null;
|
||||
day: number;
|
||||
};
|
||||
|
||||
export function fillBirthdaySmsText(template: string, firstName: string, lastName: string) {
|
||||
const name = `${firstName} ${lastName}`.trim().replace(/\s+/g, ' ');
|
||||
return template.replaceAll('{نام}', name).replaceAll('{name}', name).trim();
|
||||
}
|
||||
|
||||
function tehranJalali(d: Date) {
|
||||
const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
.format(d)
|
||||
.split('-')
|
||||
.map(Number);
|
||||
return toJalali(gy, gm, gd);
|
||||
}
|
||||
|
||||
function birthJalali(d: Date) {
|
||||
return toJalali(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
|
||||
/** متولدین ماه جاری تهران. پیشنمایش فقط روزهای امروز و بعد است. */
|
||||
export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), previewLimit = 6) {
|
||||
const today = tehranJalali(now);
|
||||
const matched = people.flatMap((person) => {
|
||||
const birth = birthJalali(person.birthDate);
|
||||
if (birth.jm !== today.jm) return [];
|
||||
return [
|
||||
{
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
mobile: person.mobile,
|
||||
day: birth.jd,
|
||||
},
|
||||
];
|
||||
});
|
||||
const byDay = (a: BirthdayPick, b: BirthdayPick) => a.day - b.day || a.firstName.localeCompare(b.firstName, 'fa');
|
||||
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
|
||||
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
|
||||
return {
|
||||
month: today.jm,
|
||||
monthName: jalaliMonthName(today.jm),
|
||||
today: today.jd,
|
||||
total: matched.length,
|
||||
preview: upcoming.slice(0, previewLimit),
|
||||
items: [...upcoming, ...passed],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -23,6 +25,11 @@ import { CreateCompanyDto, ListQueryDto, UpdateCompanyDto } from './dto';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
const excelUpload = FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
});
|
||||
|
||||
@Controller('companies')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CompaniesController {
|
||||
@@ -56,13 +63,33 @@ export class CompaniesController {
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import/preview')
|
||||
@UseInterceptors(excelUpload)
|
||||
previewImport(@UploadedFile() file?: Express.Multer.File) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
return this.excelService.previewCompanies(file);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@UseInterceptors(excelUpload)
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
@Body('mapping') mapping?: string,
|
||||
@Body('sessionId') sessionId?: string,
|
||||
@Body('offset') offset?: string,
|
||||
@Body('limit') limit?: string,
|
||||
) {
|
||||
return this.excelService.importCompanies(user, file);
|
||||
if (!file?.buffer?.length && !sessionId) {
|
||||
throw new BadRequestException('فایل ارسال نشده');
|
||||
}
|
||||
return this.excelService.importCompanies(user, file, mapping, {
|
||||
sessionId,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -23,6 +23,16 @@ import {
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
function parseDateOrNull(raw?: string | null): Date | null {
|
||||
if (!raw?.trim()) return null;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const companyInclude = {
|
||||
_count: { select: { people: true } },
|
||||
@@ -32,12 +42,26 @@ const companyInclude = {
|
||||
type CompanyRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
importSource: string;
|
||||
sourceRef: string | null;
|
||||
nationalId: string | null;
|
||||
economicCode: string | null;
|
||||
registrationNumber: string | null;
|
||||
phone: string | null;
|
||||
otherPhones: string | null;
|
||||
fax: string | null;
|
||||
email: string | null;
|
||||
otherEmails: string | null;
|
||||
website: string | null;
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
postalCode: string | null;
|
||||
address: string | null;
|
||||
groupName: string | null;
|
||||
notes: string | null;
|
||||
registeredAt: Date | null;
|
||||
tags: string[];
|
||||
customFields: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -47,7 +71,10 @@ type CompanyRow = {
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly game: GamificationService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
actor: Actor,
|
||||
@@ -55,19 +82,22 @@ export class CompaniesService {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<PaginatedResponse<CompanyDto>> {
|
||||
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
|
||||
{ name: ci(t) },
|
||||
{ email: ci(t) },
|
||||
{ phone: { contains: t } },
|
||||
{ otherPhones: { contains: t } },
|
||||
{ nationalId: { contains: t } },
|
||||
{ economicCode: { contains: t } },
|
||||
{ code: ci(t) },
|
||||
{ sourceRef: ci(t) },
|
||||
{ city: ci(t) },
|
||||
{ province: ci(t) },
|
||||
]);
|
||||
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() } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(search ? { AND: [search] } : {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
@@ -102,12 +132,26 @@ export class CompaniesService {
|
||||
const data: Prisma.CompanyUncheckedCreateInput = {
|
||||
organizationId: actor.organizationId,
|
||||
name: dto.name.trim(),
|
||||
code: emptyToNull(dto.code),
|
||||
importSource: normalizePersonImportSource(dto.importSource),
|
||||
sourceRef: emptyToNull(dto.sourceRef),
|
||||
nationalId: emptyToNull(dto.nationalId),
|
||||
economicCode: emptyToNull(dto.economicCode),
|
||||
registrationNumber: emptyToNull(dto.registrationNumber),
|
||||
phone: normalizePhone(dto.phone),
|
||||
otherPhones: emptyToNull(dto.otherPhones),
|
||||
fax: emptyToNull(dto.fax),
|
||||
email: normalizeEmail(dto.email),
|
||||
otherEmails: emptyToNull(dto.otherEmails),
|
||||
website: emptyToNull(dto.website),
|
||||
province: emptyToNull(dto.province),
|
||||
city: emptyToNull(dto.city),
|
||||
postalCode: emptyToNull(dto.postalCode),
|
||||
address: emptyToNull(dto.address),
|
||||
groupName: emptyToNull(dto.groupName),
|
||||
notes: emptyToNull(dto.notes),
|
||||
registeredAt: parseDateOrNull(dto.registeredAt),
|
||||
tags: normalizeTags(dto.tags),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
};
|
||||
@@ -117,6 +161,16 @@ export class CompaniesService {
|
||||
data,
|
||||
include: companyInclude,
|
||||
});
|
||||
if (row.importSource === 'manual') {
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
userId: actor.userId,
|
||||
event: 'COMPANY_CREATED',
|
||||
entityType: 'company',
|
||||
entityId: row.id,
|
||||
entityTitle: row.name,
|
||||
});
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -128,12 +182,30 @@ export class CompaniesService {
|
||||
await this.ensureExists(actor.organizationId, id, actor);
|
||||
const data: Prisma.CompanyUncheckedUpdateInput = {};
|
||||
if (dto.name !== undefined) data.name = dto.name.trim();
|
||||
if ('code' in dto) data.code = emptyToNull(dto.code);
|
||||
if (dto.importSource !== undefined) {
|
||||
data.importSource = normalizePersonImportSource(dto.importSource);
|
||||
}
|
||||
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
||||
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
|
||||
if ('economicCode' in dto) data.economicCode = emptyToNull(dto.economicCode);
|
||||
if ('registrationNumber' in dto) {
|
||||
data.registrationNumber = emptyToNull(dto.registrationNumber);
|
||||
}
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('otherPhones' in dto) data.otherPhones = emptyToNull(dto.otherPhones);
|
||||
if ('fax' in dto) data.fax = emptyToNull(dto.fax);
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('otherEmails' in dto) data.otherEmails = emptyToNull(dto.otherEmails);
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('province' in dto) data.province = emptyToNull(dto.province);
|
||||
if ('city' in dto) data.city = emptyToNull(dto.city);
|
||||
if ('postalCode' in dto) data.postalCode = emptyToNull(dto.postalCode);
|
||||
if ('address' in dto) data.address = emptyToNull(dto.address);
|
||||
if ('groupName' in dto) data.groupName = emptyToNull(dto.groupName);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if ('registeredAt' in dto) data.registeredAt = parseDateOrNull(dto.registeredAt);
|
||||
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
@@ -222,12 +294,26 @@ export class CompaniesService {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
importSource: row.importSource || 'manual',
|
||||
sourceRef: row.sourceRef,
|
||||
nationalId: row.nationalId,
|
||||
economicCode: row.economicCode,
|
||||
registrationNumber: row.registrationNumber,
|
||||
phone: row.phone,
|
||||
otherPhones: row.otherPhones,
|
||||
fax: row.fax,
|
||||
email: row.email,
|
||||
otherEmails: row.otherEmails,
|
||||
website: row.website,
|
||||
province: row.province,
|
||||
city: row.city,
|
||||
postalCode: row.postalCode,
|
||||
address: row.address,
|
||||
groupName: row.groupName,
|
||||
notes: row.notes,
|
||||
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
|
||||
tags: row.tags ?? [],
|
||||
customFields: asCustomFields(row.customFields),
|
||||
peopleCount: row._count.people,
|
||||
owner: row.owner,
|
||||
|
||||
@@ -3,14 +3,34 @@ import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import {
|
||||
collectAddresses,
|
||||
} from '../common/custom-fields';
|
||||
import {
|
||||
formatJalaliDate,
|
||||
parseBirthDate,
|
||||
parseExcelMapping,
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import {
|
||||
chunkImportMeta,
|
||||
resolveExcelImportChunk,
|
||||
} from '../common/excel-import-session';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleService } from './people.service';
|
||||
import { normalizeEmail, normalizePhone } from './normalize';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
|
||||
type ImportChunkOpts = {
|
||||
sessionId?: string;
|
||||
offset?: string | number;
|
||||
limit?: string | number;
|
||||
};
|
||||
|
||||
function parseMood(raw?: string): PersonMood | undefined {
|
||||
const value = raw?.trim();
|
||||
@@ -70,8 +90,17 @@ const PERSON_ALIASES: Record<string, string> = {
|
||||
گروهمشتری: 'group',
|
||||
آدرس: 'address',
|
||||
آدرس1: 'address',
|
||||
آدرساصلی: 'address',
|
||||
address: 'address',
|
||||
address2: 'address2',
|
||||
آدرس۲: 'address2',
|
||||
آدرس2: 'address2',
|
||||
آدرس_1: 'address2',
|
||||
آدرسدوم: 'address2',
|
||||
آدرسمنزل: 'homeAddress',
|
||||
آدرمنزل: 'homeAddress',
|
||||
آدرسخانه: 'homeAddress',
|
||||
homeaddress: 'homeAddress',
|
||||
کدپستی: 'postalCode',
|
||||
کدپستی1: 'postalCode',
|
||||
استانفیلدپیشفرض: 'province',
|
||||
@@ -85,17 +114,33 @@ const PERSON_ALIASES: Record<string, string> = {
|
||||
تاریختولدمشتری: 'birthDate',
|
||||
تاریختولد1: 'birthDate',
|
||||
تاریختولد: 'birthDate',
|
||||
تاریختولدشمسی: 'birthDate',
|
||||
تاریخمیلادت: 'birthDate',
|
||||
birthdate: 'birthDate',
|
||||
dateofbirth: 'birthDate',
|
||||
bdate: 'birthDate',
|
||||
منبعاطلاعات: 'source',
|
||||
نوعمشتری: 'customerType',
|
||||
type: '_type',
|
||||
_type: '_type',
|
||||
آیدیاینستاگرام: 'instagram',
|
||||
اینستاگرام: 'instagram',
|
||||
پیجاینستاگرام: 'instagram',
|
||||
instagram: 'instagram',
|
||||
معرف: 'referrer',
|
||||
سایتهایمشتری: 'website',
|
||||
تاریخثبت: 'registeredAt',
|
||||
registeredat: 'registeredAt',
|
||||
شمارهفکس: 'fax',
|
||||
تلفن2: 'phone2',
|
||||
تلفنهایدیگرمشتری: 'otherPhones',
|
||||
مسئولمشتری: 'ownerName',
|
||||
مسئول: 'ownerName',
|
||||
مسئولفروش: 'ownerName',
|
||||
کارشناسفروش: 'ownerName',
|
||||
کارشناس: 'ownerName',
|
||||
owner: 'ownerName',
|
||||
ownername: 'ownerName',
|
||||
تلفنثابتشرکت: 'companyPhone',
|
||||
کددیدارشرکت: 'companyDidarId',
|
||||
مجوزمشاهدهمشتری: 'visibility',
|
||||
@@ -107,25 +152,139 @@ const PERSON_ALIASES: Record<string, string> = {
|
||||
ستاره: 'star',
|
||||
};
|
||||
|
||||
const PERSON_FIELDS = [
|
||||
{ key: 'firstName', label: 'نام' },
|
||||
{ key: 'lastName', label: 'نام خانوادگی' },
|
||||
{ key: 'title', label: 'نام کامل / عنوان' },
|
||||
{ key: 'didarId', label: 'کد مبدأ (کد دیدار)' },
|
||||
{ key: 'customerCode', label: 'کد مشتری' },
|
||||
{ key: 'mobile', label: 'موبایل' },
|
||||
{ key: 'phone', label: 'تلفن' },
|
||||
{ key: 'email', label: 'ایمیل' },
|
||||
{ key: 'position', label: 'سمت' },
|
||||
{ key: 'companyName', label: 'شرکت' },
|
||||
{ key: 'ownerName', label: 'کارشناس فروش' },
|
||||
{ key: 'address', label: 'آدرس' },
|
||||
{ key: 'address2', label: 'آدرس ۲' },
|
||||
{ key: 'homeAddress', label: 'آدرس منزل' },
|
||||
{ key: 'instagram', label: 'اینستاگرام' },
|
||||
{ key: 'registeredAt', label: 'تاریخ ثبت' },
|
||||
{ key: 'birthDate', label: 'تاریخ تولد' },
|
||||
{ key: 'notes', label: 'یادداشت' },
|
||||
{ key: 'mood', label: 'وضعیت مشتری' },
|
||||
];
|
||||
|
||||
const COMPANY_ALIASES: Record<string, string> = {
|
||||
type: '_type',
|
||||
_type: '_type',
|
||||
name: 'name',
|
||||
نام: 'name',
|
||||
نامشرکت: 'name',
|
||||
کددیدارشرکت: 'didarId',
|
||||
didarid: 'didarId',
|
||||
کدشرکت: 'code',
|
||||
code: 'code',
|
||||
استانفیلدپیشفرض: 'province',
|
||||
استان: 'province',
|
||||
province: 'province',
|
||||
شهرستان: 'city',
|
||||
شهر: 'city',
|
||||
city: 'city',
|
||||
nationalid: 'nationalId',
|
||||
شناسهملی: 'nationalId',
|
||||
شمارهاقتصادی: 'economicCode',
|
||||
کداقتصادی: 'economicCode',
|
||||
شمارهثبت: 'registrationNumber',
|
||||
phone: 'phone',
|
||||
تلفن: 'phone',
|
||||
تلفنشرکت: 'phone',
|
||||
تلفنثابتشرکت: 'phone',
|
||||
تلفنهایدیگرشرکت: 'otherPhones',
|
||||
شمارهفکس: 'fax',
|
||||
فکس: 'fax',
|
||||
fax: 'fax',
|
||||
email: 'email',
|
||||
ایمیل: 'email',
|
||||
ایمیلشرکت: 'email',
|
||||
ایمیلهایدیگرشرکت: 'otherEmails',
|
||||
website: 'website',
|
||||
وبسایت: 'website',
|
||||
سایتهایشرکت: 'website',
|
||||
کدپستی: 'postalCode',
|
||||
postalcode: 'postalCode',
|
||||
address: 'address',
|
||||
آدرس: 'address',
|
||||
notes: 'notes',
|
||||
یادداشت: 'notes',
|
||||
توضیحاتشرکت: 'notes',
|
||||
مسئولشرکت: 'ownerName',
|
||||
مسئول: 'ownerName',
|
||||
owner: 'ownerName',
|
||||
مجوزمشاهدهشرکت: 'visibility',
|
||||
تاریخثبتشرکت: 'registeredAt',
|
||||
تاریخثبت: 'registeredAt',
|
||||
گروهبندیشرکت: 'groupName',
|
||||
گروه: 'groupName',
|
||||
};
|
||||
|
||||
const COMPANY_FIELDS = [
|
||||
{ key: 'name', label: 'نام شرکت', required: true },
|
||||
{ key: 'didarId', label: 'کد دیدار شرکت' },
|
||||
{ key: 'code', label: 'کد شرکت' },
|
||||
{ key: 'province', label: 'استان' },
|
||||
{ key: 'city', label: 'شهرستان' },
|
||||
{ key: 'phone', label: 'تلفن شرکت' },
|
||||
{ key: 'email', label: 'ایمیل شرکت' },
|
||||
{ key: 'economicCode', label: 'شماره اقتصادی' },
|
||||
{ key: 'registrationNumber', label: 'شماره ثبت' },
|
||||
{ key: 'nationalId', label: 'شناسه ملی' },
|
||||
{ key: 'fax', label: 'شماره فکس' },
|
||||
{ key: 'postalCode', label: 'کد پستی' },
|
||||
{ key: 'notes', label: 'توضیحات شرکت' },
|
||||
{ key: 'ownerName', label: 'مسئول شرکت' },
|
||||
{ key: 'visibility', label: 'مجوز مشاهده شرکت' },
|
||||
{ key: 'registeredAt', label: 'تاریخ ثبت شرکت' },
|
||||
{ key: 'groupName', label: 'گروه بندی شرکت' },
|
||||
{ key: 'otherPhones', label: 'تلفن های دیگر شرکت' },
|
||||
{ key: 'address', label: 'آدرس' },
|
||||
{ key: 'website', label: 'سایت های شرکت' },
|
||||
{ key: 'otherEmails', label: 'ایمیل های دیگر شرکت' },
|
||||
];
|
||||
|
||||
const COMPANY_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'کد دیدار شرکت', key: 'didarId', width: 14 },
|
||||
{ header: 'کد شرکت', key: 'code', width: 12 },
|
||||
{ header: 'نام شرکت', key: 'name', width: 28 },
|
||||
{ header: 'استان (فیلد پیش فرض)', key: 'province', width: 16 },
|
||||
{ header: 'شهرستان', key: 'city', width: 14 },
|
||||
{ header: 'تلفن شرکت', key: 'phone', width: 16 },
|
||||
{ header: 'ایمیل شرکت', key: 'email', width: 24 },
|
||||
{ header: 'شماره اقتصادی', key: 'economicCode', width: 16 },
|
||||
{ header: 'شماره ثبت', key: 'registrationNumber', width: 14 },
|
||||
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
|
||||
{ header: 'شماره فکس', key: 'fax', width: 14 },
|
||||
{ header: 'کد پستی', key: 'postalCode', width: 12 },
|
||||
{ header: 'توضیحات شرکت', key: 'notes', width: 32 },
|
||||
{ header: 'مسئول شرکت', key: 'ownerName', width: 16 },
|
||||
{ header: 'مجوز مشاهده شرکت', key: 'visibility', width: 18 },
|
||||
{ header: 'تاریخ ثبت شرکت', key: 'registeredAt', width: 16 },
|
||||
{ header: 'گروه بندی شرکت', key: 'groupName', width: 16 },
|
||||
{ header: 'تلفن های دیگر شرکت', key: 'otherPhones', width: 18 },
|
||||
{ header: 'آدرس', key: 'address', width: 28 },
|
||||
{ header: 'سایت های شرکت', key: 'website', width: 20 },
|
||||
{ header: 'ایمیل های دیگر شرکت', key: 'otherEmails', width: 20 },
|
||||
];
|
||||
|
||||
function parseLooseDate(raw?: string): Date | null {
|
||||
const jalali = parseJalaliDateTime(raw);
|
||||
if (jalali) return jalali;
|
||||
const v = raw?.trim();
|
||||
if (!v) return null;
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
const CONTACT_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
|
||||
@@ -183,6 +342,7 @@ const CUSTOM_KEYS = [
|
||||
'province',
|
||||
'city',
|
||||
'address',
|
||||
'address2',
|
||||
'nationalId',
|
||||
'birthDate',
|
||||
'group',
|
||||
@@ -241,23 +401,23 @@ export class ContactsExcelService {
|
||||
visibility: 'همه افراد شرکت',
|
||||
nationalId: extra.nationalId ?? '',
|
||||
registeredAt: formatJalaliDate(p.createdAt),
|
||||
birthDate: extra.birthDate ?? '',
|
||||
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||
otherPhones: extra.otherPhones ?? '',
|
||||
address: extra.address ?? '',
|
||||
website: extra.website ?? '',
|
||||
address: p.addresses[0] ?? extra.address ?? '',
|
||||
website: p.website ?? extra.website ?? '',
|
||||
otherEmails: extra.otherEmails ?? '',
|
||||
position2: p.position ?? extra.position ?? '',
|
||||
city2: extra.city ?? '',
|
||||
code2: extra.customerCode ?? '',
|
||||
group2: extra.group ?? '',
|
||||
address2: extra.address ?? '',
|
||||
address2: p.addresses[1] ?? extra.address ?? '',
|
||||
phone2: extra.phone2 ?? p.phone ?? '',
|
||||
postalCode2: extra.postalCode ?? '',
|
||||
referrer: extra.referrer ?? '',
|
||||
homeAddress: extra.homeAddress ?? '',
|
||||
birthDate2: extra.birthDate ?? '',
|
||||
instagram: extra.instagram ?? '',
|
||||
referrer: extra.referrer ?? p.referrer ?? '',
|
||||
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
|
||||
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||
instagram: p.instagram ?? extra.instagram ?? '',
|
||||
group: extra.group ?? '',
|
||||
colleagueContact: extra.colleagueContact ?? '',
|
||||
previousCompany: extra.previousCompany ?? '',
|
||||
@@ -272,60 +432,151 @@ export class ContactsExcelService {
|
||||
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
|
||||
const { items } = await this.companies.list(actor, q, 1, 5000);
|
||||
return writeXlsx(
|
||||
'شرکتها',
|
||||
[
|
||||
{ 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 },
|
||||
],
|
||||
items.map((c) => ({
|
||||
name: c.name,
|
||||
nationalId: c.nationalId ?? '',
|
||||
phone: c.phone ?? '',
|
||||
email: c.email ?? '',
|
||||
website: c.website ?? '',
|
||||
address: c.address ?? '',
|
||||
notes: c.notes ?? '',
|
||||
})),
|
||||
'Company',
|
||||
COMPANY_COLUMNS,
|
||||
items.map((c) => {
|
||||
const extra = (c.customFields ?? {}) as Record<string, string>;
|
||||
return {
|
||||
_type: 'Company',
|
||||
didarId: c.importSource === 'didar' && c.sourceRef ? c.sourceRef : '',
|
||||
code: c.code ?? '',
|
||||
name: c.name,
|
||||
province: c.province ?? '',
|
||||
city: c.city ?? '',
|
||||
phone: c.phone ?? '',
|
||||
email: c.email ?? '',
|
||||
economicCode: c.economicCode ?? '',
|
||||
registrationNumber: c.registrationNumber ?? '',
|
||||
nationalId: c.nationalId ?? '',
|
||||
fax: c.fax ?? '',
|
||||
postalCode: c.postalCode ?? '',
|
||||
notes: c.notes ?? '',
|
||||
ownerName: c.owner?.fullName ?? '',
|
||||
visibility: extra.visibility || 'همه افراد شرکت',
|
||||
registeredAt: formatJalaliDate(
|
||||
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
|
||||
),
|
||||
groupName: c.groupName ?? '',
|
||||
otherPhones: c.otherPhones ?? '',
|
||||
address: c.address ?? '',
|
||||
website: c.website ?? '',
|
||||
otherEmails: c.otherEmails ?? '',
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
previewPeople(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS);
|
||||
}
|
||||
|
||||
previewCompanies(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS);
|
||||
}
|
||||
|
||||
async importPeople(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
file: Express.Multer.File | undefined,
|
||||
mappingRaw?: string,
|
||||
chunk?: ImportChunkOpts,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES);
|
||||
const ctx = await resolveExcelImportChunk({
|
||||
organizationId: actor.organizationId,
|
||||
file,
|
||||
sessionId: chunk?.sessionId,
|
||||
offset: chunk?.offset,
|
||||
limit: chunk?.limit,
|
||||
loadRows: () =>
|
||||
readMappedRows(file!, PERSON_ALIASES, parseExcelMapping(mappingRaw)),
|
||||
});
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const ownerCache = new Map<string, string | null>();
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
for (let i = 0; i < ctx.slice.length; i++) {
|
||||
const row = ctx.slice[i]!;
|
||||
const rowNum = ctx.offset + i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
|
||||
continue;
|
||||
}
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
|
||||
if (!names) {
|
||||
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
|
||||
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
|
||||
firstName: (row.firstName ?? '').trim().slice(0, 80),
|
||||
lastName: (row.lastName ?? '').trim().slice(0, 80),
|
||||
};
|
||||
if (
|
||||
!names.firstName &&
|
||||
!names.lastName &&
|
||||
!row.mobile?.trim() &&
|
||||
!row.email?.trim() &&
|
||||
!row.phone?.trim()
|
||||
) {
|
||||
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);
|
||||
companyId = await this.findOrCreateCompanyId(
|
||||
actor,
|
||||
companyName,
|
||||
row.companyDidarId,
|
||||
row.companyPhone,
|
||||
);
|
||||
}
|
||||
|
||||
const ownerName = row.ownerName?.trim() ?? '';
|
||||
let ownerId: string | undefined;
|
||||
let ownerMissing = false;
|
||||
if (ownerName) {
|
||||
const key = ownerName.toLowerCase();
|
||||
if (!ownerCache.has(key)) {
|
||||
ownerCache.set(key, await this.findUserId(actor.organizationId, ownerName));
|
||||
}
|
||||
const matched = ownerCache.get(key);
|
||||
if (matched) ownerId = matched;
|
||||
else ownerMissing = true;
|
||||
}
|
||||
|
||||
const customFields: Record<string, string> = {};
|
||||
for (const key of CUSTOM_KEYS) {
|
||||
if (
|
||||
key === 'address' ||
|
||||
key === 'address2' ||
|
||||
key === 'homeAddress' ||
|
||||
key === 'instagram' ||
|
||||
key === 'website'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = row[key]?.trim();
|
||||
if (value) customFields[key] = value;
|
||||
}
|
||||
|
||||
const addresses = collectAddresses(
|
||||
row.address,
|
||||
row.address2,
|
||||
row.homeAddress,
|
||||
);
|
||||
const registeredAt =
|
||||
parseJalaliDateTime(row.registeredAt) ??
|
||||
(() => {
|
||||
const raw = row.registeredAt?.trim();
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
})();
|
||||
|
||||
try {
|
||||
const didarId = customFields.didarId?.trim() || '';
|
||||
const customerCode = customFields.customerCode?.trim() || '';
|
||||
const sourceCol = customFields.source?.trim() || '';
|
||||
const importSource = didarId
|
||||
? 'didar'
|
||||
: sourceCol
|
||||
? normalizePersonImportSource(sourceCol)
|
||||
: 'excel';
|
||||
const sourceRef = didarId || customerCode || null;
|
||||
|
||||
await this.people.create(actor, {
|
||||
firstName: names.firstName,
|
||||
lastName: names.lastName,
|
||||
@@ -333,14 +584,36 @@ export class ContactsExcelService {
|
||||
phone: row.phone || row.phone2 || undefined,
|
||||
mobile: row.mobile || undefined,
|
||||
position: row.position || undefined,
|
||||
website: row.website || undefined,
|
||||
instagram: row.instagram || undefined,
|
||||
birthDate: parseBirthDate(row.birthDate)?.toISOString() ?? null,
|
||||
addresses,
|
||||
notes: row.notes || undefined,
|
||||
mood: parseMood(row.mood) ?? parseMood(row.group),
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
||||
customFields,
|
||||
importSource,
|
||||
sourceRef,
|
||||
registeredAt: registeredAt?.toISOString() ?? null,
|
||||
});
|
||||
result.created += 1;
|
||||
if (ownerMissing) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: `کارشناس فروش «${ownerName}» در تیم پیدا نشد؛ به کاربر ایمپورتر اختصاص داده شد`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ConflictException) {
|
||||
const birthDate = parseBirthDate(row.birthDate);
|
||||
if (birthDate) {
|
||||
await this.people.fillBirthDateIfEmpty(actor.organizationId, {
|
||||
mobile: normalizePhone(row.mobile),
|
||||
email: normalizeEmail(row.email),
|
||||
birthDate,
|
||||
});
|
||||
}
|
||||
result.skipped += 1;
|
||||
} else {
|
||||
result.errors.push({
|
||||
@@ -351,35 +624,110 @@ export class ContactsExcelService {
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return { ...result, ...chunkImportMeta(ctx) };
|
||||
}
|
||||
|
||||
async importCompanies(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File,
|
||||
file: Express.Multer.File | undefined,
|
||||
mappingRaw?: string,
|
||||
chunk?: ImportChunkOpts,
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, COMPANY_ALIASES);
|
||||
const ctx = await resolveExcelImportChunk({
|
||||
organizationId: actor.organizationId,
|
||||
file,
|
||||
sessionId: chunk?.sessionId,
|
||||
offset: chunk?.offset,
|
||||
limit: chunk?.limit,
|
||||
loadRows: () =>
|
||||
readMappedRows(file!, COMPANY_ALIASES, parseExcelMapping(mappingRaw)),
|
||||
});
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const ownerCache = new Map<string, string | null>();
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
for (let i = 0; i < ctx.slice.length; i++) {
|
||||
const row = ctx.slice[i]!;
|
||||
const rowNum = ctx.offset + i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'company') {
|
||||
continue;
|
||||
}
|
||||
const name = row.name?.trim() ?? '';
|
||||
if (!name) {
|
||||
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const ownerName = row.ownerName?.trim() ?? '';
|
||||
let ownerId: string | undefined;
|
||||
let ownerMissing = false;
|
||||
if (ownerName) {
|
||||
const key = ownerName.toLowerCase();
|
||||
if (!ownerCache.has(key)) {
|
||||
ownerCache.set(key, await this.findUserId(actor.organizationId, ownerName));
|
||||
}
|
||||
const matched = ownerCache.get(key);
|
||||
if (matched) ownerId = matched;
|
||||
else ownerMissing = true;
|
||||
}
|
||||
|
||||
const didarId = row.didarId?.trim() || '';
|
||||
const customFields: Record<string, string> = {};
|
||||
const visibility = row.visibility?.trim();
|
||||
if (visibility) customFields.visibility = visibility;
|
||||
const registeredAt = parseLooseDate(row.registeredAt);
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
code: row.code?.trim() || null,
|
||||
importSource: didarId ? 'didar' : 'excel',
|
||||
sourceRef: didarId || null,
|
||||
nationalId: row.nationalId || undefined,
|
||||
economicCode: row.economicCode?.trim() || null,
|
||||
registrationNumber: row.registrationNumber?.trim() || null,
|
||||
phone: row.phone || undefined,
|
||||
otherPhones: row.otherPhones?.trim() || null,
|
||||
fax: row.fax?.trim() || null,
|
||||
email: row.email || undefined,
|
||||
otherEmails: row.otherEmails?.trim() || null,
|
||||
website: row.website || undefined,
|
||||
province: row.province?.trim() || null,
|
||||
city: row.city?.trim() || null,
|
||||
postalCode: row.postalCode?.trim() || null,
|
||||
address: row.address || undefined,
|
||||
groupName: row.groupName?.trim() || null,
|
||||
notes: row.notes || undefined,
|
||||
registeredAt: registeredAt?.toISOString() ?? null,
|
||||
customFields,
|
||||
};
|
||||
|
||||
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;
|
||||
const existing = await this.findImportedCompany(actor, didarId, name);
|
||||
if (existing) {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (k === 'customFields') continue;
|
||||
if (v != null && v !== '') merged[k] = v;
|
||||
}
|
||||
merged.customFields = {
|
||||
...((existing.customFields ?? {}) as Record<string, unknown>),
|
||||
...customFields,
|
||||
};
|
||||
if (ownerId) merged.ownerId = ownerId;
|
||||
await this.companies.update(actor, existing.id, merged);
|
||||
result.skipped += 1;
|
||||
} else {
|
||||
await this.companies.create(actor, {
|
||||
...payload,
|
||||
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
||||
});
|
||||
result.created += 1;
|
||||
}
|
||||
if (ownerMissing) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: `مسئول شرکت «${ownerName}» در تیم پیدا نشد؛ به کاربر ایمپورتر اختصاص داده شد`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ConflictException) {
|
||||
result.skipped += 1;
|
||||
@@ -392,20 +740,103 @@ export class ContactsExcelService {
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return { ...result, ...chunkImportMeta(ctx) };
|
||||
}
|
||||
|
||||
private async findOrCreateCompanyId(actor: Actor, name: string): Promise<string> {
|
||||
/** شرکت موجود برای ورود مجدد: اول با کد دیدار، بعد با نام (اگر کد مبدأ نداشته باشد). */
|
||||
private async findImportedCompany(actor: Actor, didarId: string, name: string) {
|
||||
if (didarId) {
|
||||
const byRef = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
sourceRef: didarId,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true, customFields: true },
|
||||
});
|
||||
if (byRef) return byRef;
|
||||
}
|
||||
return this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
name: { equals: name, mode: 'insensitive' },
|
||||
...(didarId ? { sourceRef: null } : {}),
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true, customFields: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async findOrCreateCompanyId(
|
||||
actor: Actor,
|
||||
name: string,
|
||||
didarId?: string,
|
||||
phone?: string,
|
||||
): Promise<string> {
|
||||
const ref = didarId?.trim() || '';
|
||||
if (ref) {
|
||||
const byRef = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
sourceRef: ref,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byRef) return byRef.id;
|
||||
}
|
||||
const existing = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
name: { equals: name, mode: 'insensitive' },
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true, sourceRef: true },
|
||||
});
|
||||
if (existing) {
|
||||
if (ref && !existing.sourceRef) {
|
||||
await this.prisma.company.update({
|
||||
where: { id: existing.id },
|
||||
data: { sourceRef: ref, importSource: 'didar' },
|
||||
});
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
try {
|
||||
const created = await this.companies.create(actor, {
|
||||
name,
|
||||
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
|
||||
...(phone?.trim() ? { phone: phone.trim() } : {}),
|
||||
});
|
||||
return created.id;
|
||||
} catch (e) {
|
||||
if (!(e instanceof ConflictException) || !phone) throw e;
|
||||
const created = await this.companies.create(actor, {
|
||||
name,
|
||||
...(ref ? { sourceRef: ref, importSource: 'didar' } : {}),
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
}
|
||||
|
||||
private async findUserId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const exact = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
fullName: { equals: n, mode: 'insensitive' },
|
||||
memberships: { some: { organizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return existing.id;
|
||||
const created = await this.companies.create(actor, { name });
|
||||
return created.id;
|
||||
if (exact) return exact.id;
|
||||
const partial = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
fullName: { contains: n, mode: 'insensitive' },
|
||||
memberships: { some: { organizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return partial?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@ import { Module } from '@nestjs/common';
|
||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { PublicApiModule } from '../public-api/public-api.module';
|
||||
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';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
|
||||
@Module({
|
||||
imports: [LoyaltyModule, AutomationsModule, MessageAutomationsModule],
|
||||
imports: [
|
||||
LoyaltyModule,
|
||||
AutomationsModule,
|
||||
MessageAutomationsModule,
|
||||
PublicApiModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user