merge: bring latest origin/main into dev (keep local UI)
Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"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 test/behavior.test.ts test/attendance-time.test.ts"
|
||||
"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 test/behavior.test.ts test/attendance-time.test.ts test/sales-factors.test.ts test/platform-admin-alert.test.ts test/person-name.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mento/shared": "*",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- تیکهای مجوز نقش از این نسخه واقعاً اعمال میشوند.
|
||||
-- تا امروز همهٔ نقشها عملاً به این بخشها دسترسی داشتند؛ همان دسترسی را برای نقشهای موجود حفظ میکنیم.
|
||||
UPDATE "OrgRole"
|
||||
SET "permissions" = ARRAY(
|
||||
SELECT DISTINCT p
|
||||
FROM unnest(
|
||||
"permissions" || ARRAY['customers', 'leads', 'sales', 'billing', 'products', 'activities', 'notes', 'reports']
|
||||
) AS p
|
||||
);
|
||||
|
||||
-- لاگ فعالیت کاربران تا امروز فقط برای مالک و مدیر باز بود (مستقل از تیک)؛ همان را حفظ میکنیم.
|
||||
UPDATE "OrgRole"
|
||||
SET "permissions" = array_remove("permissions", 'audit')
|
||||
WHERE "systemKey" IS NULL OR "systemKey" NOT IN ('owner', 'admin');
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,85 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CustomerGroup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT 'teal',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"tahesabConnectionId" TEXT,
|
||||
"tahesabGid" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CustomerGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PersonGroupLink" (
|
||||
"personId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT 'manual',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PersonGroupLink_pkey" PRIMARY KEY ("personId","groupId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CompanyGroupLink" (
|
||||
"companyId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CompanyGroupLink_pkey" PRIMARY KEY ("companyId","groupId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CustomerGroup_organizationId_name_key" ON "CustomerGroup"("organizationId", "name");
|
||||
CREATE INDEX "CustomerGroup_organizationId_position_idx" ON "CustomerGroup"("organizationId", "position");
|
||||
CREATE INDEX "CustomerGroup_tahesabConnectionId_tahesabGid_idx" ON "CustomerGroup"("tahesabConnectionId", "tahesabGid");
|
||||
CREATE INDEX "PersonGroupLink_groupId_idx" ON "PersonGroupLink"("groupId");
|
||||
CREATE INDEX "CompanyGroupLink_groupId_idx" ON "CompanyGroupLink"("groupId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerGroup" ADD CONSTRAINT "CustomerGroup_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- تبدیل دادههای قبلی: «گروه مشتری» اکسل (customFields.group) و «گروهبندی شرکت» (groupName).
|
||||
-- مقدارهای چندتایی با «،» «,» «/» جدا میشوند. برچسبهای «وضعیت مشتری» که دیدار در همان ستون میگذارد گروه حساب نمیشوند.
|
||||
CREATE TEMP TABLE "_cg_src" AS
|
||||
SELECT p."organizationId" AS org, 'person' AS kind, p."id" AS ref, left(btrim(x), 80) AS name
|
||||
FROM "Person" p
|
||||
CROSS JOIN LATERAL regexp_split_to_table(p."customFields"->>'group', '\s*[،,/]\s*') AS x
|
||||
WHERE COALESCE(p."customFields"->>'group', '') <> ''
|
||||
UNION ALL
|
||||
SELECT c."organizationId", 'company', c."id", left(btrim(x), 80)
|
||||
FROM "Company" c
|
||||
CROSS JOIN LATERAL regexp_split_to_table(c."groupName", '\s*[،,/]\s*') AS x
|
||||
WHERE COALESCE(c."groupName", '') <> '';
|
||||
|
||||
DELETE FROM "_cg_src"
|
||||
WHERE name = ''
|
||||
OR name IN ('مشتری تازه وارد', 'مشتری خوشحال', 'مشتری ناراضی', 'مشتری خاص');
|
||||
|
||||
INSERT INTO "CustomerGroup" ("id", "organizationId", "name", "color", "position", "createdAt", "updatedAt")
|
||||
SELECT 'cgrp_' || md5(d.org || '|' || d.name), d.org, d.name, 'teal', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT DISTINCT org, name FROM "_cg_src") AS d
|
||||
ON CONFLICT ("organizationId", "name") DO NOTHING;
|
||||
|
||||
INSERT INTO "PersonGroupLink" ("personId", "groupId", "source", "createdAt")
|
||||
SELECT DISTINCT s.ref, g."id", 'manual', CURRENT_TIMESTAMP
|
||||
FROM "_cg_src" s
|
||||
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
|
||||
WHERE s.kind = 'person'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO "CompanyGroupLink" ("companyId", "groupId", "createdAt")
|
||||
SELECT DISTINCT s.ref, g."id", CURRENT_TIMESTAMP
|
||||
FROM "_cg_src" s
|
||||
JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name
|
||||
WHERE s.kind = 'company'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DROP TABLE "_cg_src";
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Deep data access: role default + membership override (phase 1: customers)
|
||||
|
||||
ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "dataAccessPolicy" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataAccessOverride" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Migrate legacy own/team/org (+ optional contacts scope) into customers override
|
||||
UPDATE "Membership" m
|
||||
SET "dataAccessOverride" = jsonb_build_object(
|
||||
'customers',
|
||||
CASE
|
||||
WHEN COALESCE(
|
||||
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
|
||||
m."dataVisibility"
|
||||
) = 'org' THEN '{"mode":"all","rules":[]}'::jsonb
|
||||
WHEN COALESCE(
|
||||
NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''),
|
||||
m."dataVisibility"
|
||||
) = 'team' THEN '{"mode":"rules","rules":[{"owners":["self","team"]}]}'::jsonb
|
||||
ELSE '{"mode":"rules","rules":[{"owners":["self"]}]}'::jsonb
|
||||
END
|
||||
)
|
||||
WHERE m."role" NOT IN ('owner', 'admin')
|
||||
AND (
|
||||
m."dataAccessOverride" IS NULL
|
||||
OR m."dataAccessOverride"::text IN ('{}', 'null')
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingAsnadLine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"personId" TEXT,
|
||||
"linkId" TEXT,
|
||||
"moshtariCode" INTEGER NOT NULL,
|
||||
"lineKey" TEXT NOT NULL,
|
||||
"tahesabId" INTEGER,
|
||||
"factorCode" TEXT,
|
||||
"shFactor" INTEGER,
|
||||
"radif" INTEGER,
|
||||
"tarikh" TEXT NOT NULL,
|
||||
"noRaw" TEXT NOT NULL DEFAULT '',
|
||||
"kind" TEXT NOT NULL DEFAULT 'other',
|
||||
"jensFelez" INTEGER,
|
||||
"noKar" TEXT,
|
||||
"vazn" DOUBLE PRECISION,
|
||||
"tabdilVazn" DOUBLE PRECISION,
|
||||
"mali" DOUBLE PRECISION,
|
||||
"count" DOUBLE PRECISION,
|
||||
"ayar" DOUBLE PRECISION,
|
||||
"mazaneh" DOUBLE PRECISION,
|
||||
"fi" DOUBLE PRECISION,
|
||||
"sharh1" TEXT,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AccountingAsnadLine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingAsnadLine_connectionId_lineKey_key" ON "AccountingAsnadLine"("connectionId", "lineKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingAsnadLine_organizationId_connectionId_tarikh_idx" ON "AccountingAsnadLine"("organizationId", "connectionId", "tarikh");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingAsnadLine_organizationId_kind_idx" ON "AccountingAsnadLine"("organizationId", "kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingAsnadLine_personId_idx" ON "AccountingAsnadLine"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingAsnadLine_linkId_idx" ON "AccountingAsnadLine"("linkId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingAsnadLine_moshtariCode_idx" ON "AccountingAsnadLine"("moshtariCode");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "AccountingConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AccountingAsnadLine" ADD CONSTRAINT "AccountingAsnadLine_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "PersonAccountingLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TahesabInvoiceSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"logoDataUrl" TEXT,
|
||||
"companyName" TEXT NOT NULL DEFAULT '',
|
||||
"companySubtitle" TEXT NOT NULL DEFAULT '',
|
||||
"address" TEXT NOT NULL DEFAULT '',
|
||||
"phone" TEXT NOT NULL DEFAULT '',
|
||||
"headerText" TEXT NOT NULL DEFAULT '',
|
||||
"footerText" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TahesabInvoiceSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TahesabInvoiceSettings_organizationId_key" ON "TahesabInvoiceSettings"("organizationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TahesabInvoiceSettings" ADD CONSTRAINT "TahesabInvoiceSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE "CustomerGroup" ADD COLUMN IF NOT EXISTS "parentId" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "CustomerGroup_parentId_idx" ON "CustomerGroup"("parentId");
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "CustomerGroup"
|
||||
ADD CONSTRAINT "CustomerGroup_parentId_fkey"
|
||||
FOREIGN KEY ("parentId") REFERENCES "CustomerGroup"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsad" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "darsadTalayee" DOUBLE PRECISION;
|
||||
ALTER TABLE "AccountingAsnadLine" ADD COLUMN IF NOT EXISTS "maliatDarsad" DOUBLE PRECISION;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActivityImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"activityId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"uploadedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- پیامک پایان اشتراک فقط برای انقضاهای بعدی؛ حسابهای قبلاً تمامشده دوباره خبر نمیدهند
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);
|
||||
|
||||
UPDATE "BillingAccount"
|
||||
SET "expiryNotifiedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "planCode" = 'lifetime'
|
||||
OR "status" = 'expired'
|
||||
OR "expiresAt" < CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "ActivityBoardMember" (
|
||||
"boardId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId")
|
||||
);
|
||||
|
||||
CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId");
|
||||
|
||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,22 @@
|
||||
ALTER TABLE "Person" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "Person" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- نامهایی که از تهحساب با عنوان آمدهاند؛ خود متن نام دست نمیخورد
|
||||
UPDATE "Person"
|
||||
SET "gender" = 'male', "showGenderInName" = true
|
||||
WHERE regexp_replace(
|
||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||
|
||||
UPDATE "Person"
|
||||
SET "gender" = 'female', "showGenderInName" = true
|
||||
WHERE "gender" = 'none'
|
||||
AND regexp_replace(
|
||||
regexp_replace(btrim("firstName" || ' ' || "lastName"), 'ي', 'ی', 'g'),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE "Lead" ADD COLUMN "gender" TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE "Lead" ADD COLUMN "showGenderInName" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- عنوان داخل نام مخاطب، و اگر مخاطب خالی است داخل عنوان لید
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'male', "showGenderInName" = true
|
||||
WHERE regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^جناب[[:space:]]*آقای([[:space:]]|$)';
|
||||
|
||||
UPDATE "Lead"
|
||||
SET "gender" = 'female', "showGenderInName" = true
|
||||
WHERE "gender" = 'none'
|
||||
AND regexp_replace(
|
||||
regexp_replace(
|
||||
btrim(CASE WHEN btrim(coalesce("contactName", '')) <> '' THEN "contactName" ELSE "title" END),
|
||||
'ي',
|
||||
'ی',
|
||||
'g'
|
||||
),
|
||||
'ك',
|
||||
'ک',
|
||||
'g'
|
||||
) ~ '^سرکار[[:space:]]*خانم([[:space:]]|$)';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TYPE "CustomFieldEntity" ADD VALUE IF NOT EXISTS 'note';
|
||||
|
||||
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "customFields" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "NoteCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"tone" TEXT NOT NULL DEFAULT 'general',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showInBar" 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 "NoteCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "NoteCategory_organizationId_key_key" ON "NoteCategory"("organizationId", "key");
|
||||
CREATE INDEX "NoteCategory_organizationId_position_idx" ON "NoteCategory"("organizationId", "position");
|
||||
|
||||
ALTER TABLE "NoteCategory" ADD CONSTRAINT "NoteCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "Organization" ADD COLUMN "fileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "Organization" ADD COLUMN "chatFileBytes" BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "Organization" ADD COLUMN "fileBytesSyncedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "storageQuotaBytes" BIGINT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "BillingAccount" ADD COLUMN "maxUsers" INTEGER;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "PlatformMasterPassword" (
|
||||
"id" TEXT NOT NULL DEFAULT 'default',
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- دموی ۷ روزه که هنوز سقف کاربر ندارد، پیشفرض ۳ کاربر میگیرد
|
||||
UPDATE "BillingAccount"
|
||||
SET "maxUsers" = 3
|
||||
WHERE "planCode" = 'trial' AND "maxUsers" IS NULL;
|
||||
@@ -52,6 +52,7 @@ enum CustomFieldEntity {
|
||||
person
|
||||
company
|
||||
deal
|
||||
note
|
||||
}
|
||||
|
||||
enum CustomFieldType {
|
||||
@@ -153,6 +154,12 @@ model Organization {
|
||||
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
||||
priorArchiveEnabledAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
/// حجم فایلهای محلی این محیط، به بایت. متن رکوردها (مشتری، فعالیت، فاکتور) داخلش نیست.
|
||||
fileBytes BigInt @default(0)
|
||||
/// حجم فایلهای گفتگو که از این محیط آپلود شدهاند
|
||||
chatFileBytes BigInt @default(0)
|
||||
/// آخرین باری که fileBytes از روی فایلهای واقعی محاسبه شده
|
||||
fileBytesSyncedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
@@ -160,6 +167,7 @@ model Organization {
|
||||
orgRoles OrgRole[]
|
||||
teams Team[]
|
||||
companies Company[]
|
||||
customerGroups CustomerGroup[]
|
||||
people Person[]
|
||||
attendanceEvents AttendanceEvent[]
|
||||
discAssessments DiscAssessment[]
|
||||
@@ -168,11 +176,13 @@ model Organization {
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
notes Note[]
|
||||
noteCategories NoteCategory[]
|
||||
noteRevisions NoteRevision[]
|
||||
customFields CustomFieldDefinition[]
|
||||
products Product[]
|
||||
notifications Notification[]
|
||||
noteAttachments NoteAttachment[]
|
||||
activityImages ActivityImage[]
|
||||
leadForms LeadForm[]
|
||||
quotes Quote[]
|
||||
invoices Invoice[]
|
||||
@@ -195,6 +205,8 @@ model Organization {
|
||||
noteAiSuggestions NoteAiSuggestion[]
|
||||
accountingConnections AccountingConnection[]
|
||||
accountingSyncJobs AccountingSyncJob[]
|
||||
accountingAsnadLines AccountingAsnadLine[]
|
||||
tahesabInvoiceSettings TahesabInvoiceSettings?
|
||||
warehouses Warehouse[]
|
||||
stockMovements StockMovement[]
|
||||
partyOpenings PartyOpeningBalance[]
|
||||
@@ -290,6 +302,7 @@ model User {
|
||||
notes Note[] @relation("NoteAuthor")
|
||||
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
|
||||
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
||||
activityImages ActivityImage[] @relation("ActivityImageUploader")
|
||||
notifications Notification[]
|
||||
salesGoals SalesGoal[] @relation("SalesGoalUser")
|
||||
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
|
||||
@@ -306,6 +319,7 @@ model User {
|
||||
tablePreferences UserTablePreference[]
|
||||
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
||||
teamMemberships TeamMember[]
|
||||
activityBoardMembers ActivityBoardMember[]
|
||||
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
|
||||
assignedLeads Lead[] @relation("LeadAssignee")
|
||||
createdLeads Lead[] @relation("LeadCreator")
|
||||
@@ -337,6 +351,8 @@ model OrgRole {
|
||||
description String @default("")
|
||||
color String @default("teal")
|
||||
permissions String[] @default([])
|
||||
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
||||
dataAccessPolicy Json @default("{}")
|
||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||
systemKey String?
|
||||
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||
@@ -369,12 +385,18 @@ model Membership {
|
||||
dataVisibility String @default("own")
|
||||
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخشهای بدون مقدار = dataVisibility
|
||||
dataVisibilityScopes Json @default("{}")
|
||||
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
||||
dataAccessOverride Json @default("{}")
|
||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||
priorArchiveEnabled Boolean @default(false)
|
||||
/// current | archive
|
||||
priorArchiveLens String @default("current")
|
||||
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
|
||||
logoutTime String?
|
||||
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
|
||||
blockLoginAfterLogout Boolean @default(false)
|
||||
/// ورود به این محیط فقط با OTP؛ رمز عبور برای این عضویت کار نمیکند
|
||||
loginOtpOnly Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -429,6 +451,13 @@ model PlatformAdmin {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز میشود وارد حساب CRM او شد
|
||||
model PlatformMasterPassword {
|
||||
id String @id @default("default")
|
||||
passwordHash String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
|
||||
model BillingAccount {
|
||||
id String @id @default(cuid())
|
||||
@@ -441,9 +470,15 @@ model BillingAccount {
|
||||
startsAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
maxOrganizations Int @default(1)
|
||||
/// سقف حجم فایل همهٔ محیطهای این مالک، به بایت. null = بدون سقف
|
||||
storageQuotaBytes BigInt?
|
||||
/// سقف کاربر فعال همهٔ محیطهای این مالک. null = هنوز تعیین نشده
|
||||
maxUsers Int?
|
||||
/// رزرو برای درگاه/لندینگ بعدی
|
||||
externalPaymentRef String?
|
||||
notes String?
|
||||
/// پیامک پایان اشتراک برای مدیران پنل؛ خالی یعنی هنوز اطلاع داده نشده
|
||||
expiryNotifiedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -475,11 +510,13 @@ model Company {
|
||||
city String?
|
||||
postalCode String?
|
||||
address String?
|
||||
/// قدیمی؛ از نسخهٔ گروهبندی، گروهها در CompanyGroupLink هستند
|
||||
groupName String?
|
||||
notes String?
|
||||
registeredAt DateTime?
|
||||
tags String[] @default([])
|
||||
customFields Json @default("{}")
|
||||
groupLinks CompanyGroupLink[]
|
||||
people Person[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
@@ -503,6 +540,56 @@ model Company {
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
/// گروه مشتری (مشترک بین اشخاص و شرکتها؛ هر مشتری چند گروه)
|
||||
model CustomerGroup {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
color String @default("teal")
|
||||
position Int @default(0)
|
||||
/// گروه متناظر در تهحساب (GID) روی همان اتصال
|
||||
tahesabConnectionId String?
|
||||
tahesabGid String?
|
||||
/// گروه اصلی؛ فقط یک سطح (اصلی / فرعی)
|
||||
parentId String?
|
||||
parent CustomerGroup? @relation("CustomerGroupTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children CustomerGroup[] @relation("CustomerGroupTree")
|
||||
people PersonGroupLink[]
|
||||
companies CompanyGroupLink[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, name])
|
||||
@@index([organizationId, position])
|
||||
@@index([tahesabConnectionId, tahesabGid])
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model PersonGroupLink {
|
||||
personId String
|
||||
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
|
||||
groupId String
|
||||
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
/// manual | tahesab — گروهِ تهحسابی با همگامسازی جایگزین میشود؛ دستی دست نمیخورد
|
||||
source String @default("manual")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([personId, groupId])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
model CompanyGroupLink {
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
groupId String
|
||||
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([companyId, groupId])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
model Person {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
@@ -511,6 +598,10 @@ model Person {
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
firstName String
|
||||
lastName String
|
||||
/// male | female | none
|
||||
gender String @default("none")
|
||||
/// اگر روشن باشد، مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره میشود
|
||||
showGenderInName Boolean @default(false)
|
||||
/// نام قبل از همگامسازی مبدأ (مثلاً تهحساب)
|
||||
previousFirstName String?
|
||||
previousLastName String?
|
||||
@@ -539,6 +630,7 @@ model Person {
|
||||
mood PersonMood @default(newcomer)
|
||||
tags String[] @default([])
|
||||
customFields Json @default("{}")
|
||||
groupLinks PersonGroupLink[]
|
||||
loyaltyPoints Int @default(0)
|
||||
loyaltyTierId String?
|
||||
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
|
||||
@@ -557,6 +649,7 @@ model Person {
|
||||
memoryItems MemoryItem[]
|
||||
accountingLinks PersonAccountingLink[]
|
||||
accountingDocuments AccountingDocumentLog[]
|
||||
asnadLines AccountingAsnadLine[]
|
||||
openingBalances PartyOpeningBalance[]
|
||||
cheques Cheque[]
|
||||
trackingLinks TrackingLink[]
|
||||
@@ -697,6 +790,7 @@ model ActivityBoard {
|
||||
position Int @default(0)
|
||||
stages ActivityStage[]
|
||||
activities Activity[]
|
||||
members ActivityBoardMember[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -704,6 +798,17 @@ model ActivityBoard {
|
||||
@@index([organizationId, position])
|
||||
}
|
||||
|
||||
/// عضو گروه فعالیت؛ اگر گروهی هیچ عضوی نداشته باشد برای همهٔ محیط باز است
|
||||
model ActivityBoardMember {
|
||||
boardId String
|
||||
board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([boardId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ActivityStage {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
@@ -801,6 +906,7 @@ model Activity {
|
||||
/// لید مرتبط (مثلاً فعالیت ساختهشده از ارسال فرم)
|
||||
leadId String?
|
||||
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
|
||||
images ActivityImage[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -819,6 +925,26 @@ model Activity {
|
||||
@@index([dealId])
|
||||
}
|
||||
|
||||
/// عکس فشردهشدهٔ یادداشت فعالیت (حداکثر ۲۰۰ کیلوبایت)
|
||||
model ActivityImage {
|
||||
id String @id
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
activityId String
|
||||
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
|
||||
fileName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
storageKey String
|
||||
uploadedById String?
|
||||
uploadedBy User? @relation("ActivityImageUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([activityId, createdAt])
|
||||
@@index([organizationId])
|
||||
@@index([uploadedById])
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
@@ -834,6 +960,8 @@ model Note {
|
||||
checklist Json?
|
||||
locationText String?
|
||||
analyzeEnabled Boolean @default(true)
|
||||
/// مقادیر فیلدهای سفارشی یادداشت
|
||||
customFields Json @default("{}")
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
@@ -863,6 +991,26 @@ model Note {
|
||||
@@index([authorId])
|
||||
}
|
||||
|
||||
/// دستههای نوار فیلتر یادداشت؛ برای هر سازمان قابل تغییر است
|
||||
model NoteCategory {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
key String
|
||||
label String
|
||||
tone String @default("general")
|
||||
position Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
/// در نوار «همه / معمولی / پیگیری / …» دیده شود
|
||||
showInBar Boolean @default(true)
|
||||
isSystem Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, key])
|
||||
@@index([organizationId, position])
|
||||
}
|
||||
|
||||
/// اسنپشات متن قبل از هر ویرایش معنادار یادداشت
|
||||
model NoteRevision {
|
||||
id String @id @default(cuid())
|
||||
@@ -1690,6 +1838,7 @@ model AccountingConnection {
|
||||
productLinks ProductAccountingLink[]
|
||||
documentLogs AccountingDocumentLog[]
|
||||
balanceCaches AccountingBalanceCache[]
|
||||
asnadLines AccountingAsnadLine[]
|
||||
|
||||
@@unique([organizationId, baseUrl, dbName])
|
||||
@@index([organizationId, enabled])
|
||||
@@ -1709,6 +1858,7 @@ model PersonAccountingLink {
|
||||
linkedAt DateTime @default(now())
|
||||
lastBalanceSyncedAt DateTime?
|
||||
balanceCache AccountingBalanceCache?
|
||||
asnadLines AccountingAsnadLine[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1787,6 +1937,55 @@ model AccountingBalanceCache {
|
||||
@@index([connectionId])
|
||||
}
|
||||
|
||||
/// اسنپشات ردیفهای DoListAsnad برای گزارش فروش گروهی
|
||||
model AccountingAsnadLine {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
connectionId String
|
||||
connection AccountingConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
linkId String?
|
||||
link PersonAccountingLink? @relation(fields: [linkId], references: [id], onDelete: SetNull)
|
||||
moshtariCode Int
|
||||
/// کلید پایدار برای upsert: ID تهحساب یا f:factor|r:radif|t:tarikh|n:no
|
||||
lineKey String
|
||||
tahesabId Int?
|
||||
factorCode String?
|
||||
shFactor Int?
|
||||
radif Int?
|
||||
/// شمسی YYYY-MM-DD
|
||||
tarikh String
|
||||
noRaw String @default("")
|
||||
/// sale | return | purchase | other
|
||||
kind String @default("other")
|
||||
jensFelez Int?
|
||||
noKar String?
|
||||
vazn Float?
|
||||
tabdilVazn Float?
|
||||
mali Float?
|
||||
count Float?
|
||||
ayar Float?
|
||||
/// درصد اجرت سند تهحساب؛ بعد از همگامسازی پر میشود
|
||||
darsad Float?
|
||||
darsadTalayee Float?
|
||||
maliatDarsad Float?
|
||||
mazaneh Float?
|
||||
fi Float?
|
||||
sharh1 String?
|
||||
syncedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([connectionId, lineKey])
|
||||
@@index([organizationId, connectionId, tarikh])
|
||||
@@index([organizationId, kind])
|
||||
@@index([personId])
|
||||
@@index([linkId])
|
||||
@@index([moshtariCode])
|
||||
}
|
||||
|
||||
model AccountingSyncJob {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
@@ -1804,6 +2003,23 @@ model AccountingSyncJob {
|
||||
@@index([organizationId, createdAt])
|
||||
}
|
||||
|
||||
/// تنظیمات ظاهر فاکتور PDF اسناد تهحساب (جدا از فاکتور CRM)
|
||||
model TahesabInvoiceSettings {
|
||||
id String @id @default(cuid())
|
||||
organizationId String @unique
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
/// data URL تصویر لوگو (مثلاً data:image/png;base64,...)
|
||||
logoDataUrl String? @db.Text
|
||||
companyName String @default("")
|
||||
companySubtitle String @default("")
|
||||
address String @default("")
|
||||
phone String @default("")
|
||||
headerText String @default("")
|
||||
footerText String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// انبار داخلی — وقتی تهحساب وصل نیست
|
||||
model Warehouse {
|
||||
id String @id @default(cuid())
|
||||
@@ -2848,6 +3064,10 @@ model Lead {
|
||||
title String
|
||||
/// نام مالک یا مسئول
|
||||
contactName String?
|
||||
/// male | female | none
|
||||
gender String @default("none")
|
||||
/// عنوان «جناب آقای» / «سرکار خانم» داخل نام مخاطب
|
||||
showGenderInName Boolean @default(false)
|
||||
mobile String
|
||||
phone String?
|
||||
city String?
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -50,7 +51,8 @@ const excelUpload = FileInterceptor('file', {
|
||||
});
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('activities')
|
||||
export class ActivitiesController {
|
||||
constructor(
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
@@ -70,14 +72,14 @@ export class ActivitiesController {
|
||||
|
||||
@Get('boards')
|
||||
listBoards(@CurrentUser() user: Actor) {
|
||||
return this.stagesService.listBoards(user.organizationId);
|
||||
return this.stagesService.listBoards(user.organizationId, user);
|
||||
}
|
||||
|
||||
@Post('boards')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
|
||||
return this.stagesService.createBoard(user.organizationId, dto.name);
|
||||
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
|
||||
}
|
||||
|
||||
@Patch('boards/:id')
|
||||
@@ -101,7 +103,7 @@ export class ActivitiesController {
|
||||
|
||||
@Get('stages')
|
||||
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
|
||||
return this.stagesService.list(user.organizationId, query.boardId);
|
||||
return this.stagesService.list(user.organizationId, query.boardId, user);
|
||||
}
|
||||
|
||||
@Post('stages')
|
||||
@@ -191,6 +193,9 @@ export class ActivitiesController {
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
);
|
||||
@@ -224,6 +229,42 @@ export class ActivitiesController {
|
||||
typeDefId: query.typeDefId,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** ترتیب همان لیست میز، برای فعالیت قبلی/بعدی در جزئیات */
|
||||
@Get('nav-ids')
|
||||
navIds(@CurrentUser() user: Actor, @Query() query: ListActivitiesQueryDto) {
|
||||
return this.activitiesService.orderedIds(
|
||||
user.organizationId,
|
||||
user,
|
||||
query.filter ?? 'today',
|
||||
query.q,
|
||||
{
|
||||
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,
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
@@ -307,6 +348,56 @@ export class ActivitiesController {
|
||||
return this.activitiesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post(':id/images')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
addImage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.activitiesService.addImage(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
file,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/images/:imageId')
|
||||
async downloadImage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Param('imageId') imageId: string,
|
||||
): Promise<StreamableFile> {
|
||||
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
|
||||
user.organizationId,
|
||||
id,
|
||||
imageId,
|
||||
user,
|
||||
);
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return new StreamableFile(stream, {
|
||||
type: mimeType,
|
||||
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id/images/:imageId')
|
||||
async removeImage(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Param('imageId') imageId: string,
|
||||
) {
|
||||
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(':id/promote-from-prior')
|
||||
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.promoteFromPrior(user, id);
|
||||
|
||||
@@ -7,9 +7,10 @@ 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';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [
|
||||
ActivitiesService,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ActivityDto,
|
||||
ActivityFieldValues,
|
||||
ActivityFilter,
|
||||
ActivityImageDto,
|
||||
ActivitySummaryDto,
|
||||
ActivityStageCountsDto,
|
||||
CreateActivityRequest,
|
||||
@@ -15,6 +16,10 @@ import type {
|
||||
UpdateActivityRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as path from 'node:path';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
@@ -25,11 +30,13 @@ import {
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { assertAssigneeSet } from '../common/assign-access';
|
||||
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 { leadsDeepWhere } from '../common/data-access';
|
||||
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
|
||||
import {
|
||||
ActivityTypesService,
|
||||
@@ -39,7 +46,10 @@ import {
|
||||
|
||||
type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
|
||||
type ActivityActor = Pick<
|
||||
Actor,
|
||||
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
|
||||
>;
|
||||
|
||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||
type StageScope = { stageId: string };
|
||||
@@ -49,6 +59,10 @@ type ListFacets = {
|
||||
typeDefId?: string;
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
title?: string;
|
||||
customer?: string;
|
||||
/** set = زمان شروع دارد، unset = ندارد */
|
||||
due?: 'set' | 'unset';
|
||||
/** گروه فعالیت؛ خالی = همهٔ گروهها */
|
||||
boardId?: string;
|
||||
};
|
||||
@@ -84,6 +98,57 @@ type ActivityRow = {
|
||||
typeDef?: { id: string; name: string; icon: string; color: string } | null;
|
||||
fields?: Prisma.JsonValue | null;
|
||||
participantIds?: string[];
|
||||
images?: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
|
||||
const ACTIVITY_IMAGE_MAX_COUNT = 8;
|
||||
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const ACTIVITY_VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const ACTIVITY_VOICE_MAX_COUNT = 5;
|
||||
const ACTIVITY_VOICE_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/x-m4a', 'audio/m4a', 'audio/wav', 'audio/x-wav']);
|
||||
const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const ACTIVITY_FILE_MAX_COUNT = 8;
|
||||
const ACTIVITY_FILE_MIME = new Set([
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
]);
|
||||
const ACTIVITY_EXT_MIME: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
|
||||
const include = {
|
||||
@@ -108,6 +173,8 @@ export class ActivitiesService {
|
||||
private readonly stages: ActivityStagesService,
|
||||
private readonly types: ActivityTypesService,
|
||||
private readonly game: GamificationService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly storage: StorageQuotaService,
|
||||
) {}
|
||||
|
||||
private async ctx(organizationId: string): Promise<DtoContext> {
|
||||
@@ -245,6 +312,105 @@ export class ActivitiesService {
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
): Promise<PaginatedResponse<ActivityDto>> {
|
||||
const { where, ctx } = await this.listScope(
|
||||
organizationId,
|
||||
actor,
|
||||
filter,
|
||||
q,
|
||||
entity,
|
||||
range,
|
||||
desk,
|
||||
facets,
|
||||
stageScope,
|
||||
);
|
||||
const take = Math.min(Math.max(limit, 1), 200);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.activity.findMany({
|
||||
where,
|
||||
include,
|
||||
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
||||
orderBy: entity?.leadId
|
||||
? [{ createdAt: 'desc' }]
|
||||
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * take,
|
||||
take,
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row, ctx)),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* شناسهها با همان فیلتر و ترتیب لیست (زمان شروع صعودی).
|
||||
* برای ورقزدن جزئیات؛ سقف دارد تا یک زبانهٔ خیلی شلوغ کل پاسخ را بزرگ نکند.
|
||||
*/
|
||||
async orderedIds(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
filter: ActivityFilter = 'today',
|
||||
q?: string,
|
||||
entity?: {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
},
|
||||
range?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
},
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: ListFacets,
|
||||
stageScope?: StageScope,
|
||||
): Promise<{ ids: string[]; total: number; truncated: boolean }> {
|
||||
const cap = 2000;
|
||||
const { where } = await this.listScope(
|
||||
organizationId,
|
||||
actor,
|
||||
filter,
|
||||
q,
|
||||
entity,
|
||||
range,
|
||||
desk,
|
||||
facets,
|
||||
stageScope,
|
||||
);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.activity.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
take: cap,
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
]);
|
||||
return { ids: rows.map((row) => row.id), total, truncated: total > rows.length };
|
||||
}
|
||||
|
||||
private async listScope(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
filter: ActivityFilter,
|
||||
q: string | undefined,
|
||||
entity:
|
||||
| {
|
||||
personId?: string;
|
||||
companyId?: string;
|
||||
dealId?: string;
|
||||
leadId?: string;
|
||||
}
|
||||
| undefined,
|
||||
range: { from?: string; to?: string } | undefined,
|
||||
desk: 'mine' | 'team' | undefined,
|
||||
facets: ListFacets | undefined,
|
||||
stageScope: StageScope | undefined,
|
||||
): Promise<{ where: Prisma.ActivityWhereInput; ctx: DtoContext }> {
|
||||
const typeRows = await this.types.rows(organizationId);
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
@@ -261,6 +427,7 @@ export class ActivitiesService {
|
||||
const boardCtx = await this.stages.boardContext(organizationId);
|
||||
if (facets?.boardId) {
|
||||
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
|
||||
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
|
||||
(where.AND as Prisma.ActivityWhereInput[]).push(
|
||||
this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
|
||||
);
|
||||
@@ -270,27 +437,9 @@ export class ActivitiesService {
|
||||
await this.stages.whereForStage(organizationId, stageScope.stageId),
|
||||
);
|
||||
}
|
||||
const ctx: DtoContext = {
|
||||
stages: boardCtx,
|
||||
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({
|
||||
where,
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * take,
|
||||
take,
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row, ctx)),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
where,
|
||||
ctx: { stages: boardCtx, types: this.types.systemOf(typeRows) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -304,6 +453,7 @@ export class ActivitiesService {
|
||||
facets?: ListFacets,
|
||||
): Promise<ActivityStageCountsDto> {
|
||||
const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
|
||||
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
|
||||
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
||||
const rows = await this.stages.rows(organizationId, boardId);
|
||||
const defaults = this.stages.defaultsOf(rows);
|
||||
@@ -382,7 +532,19 @@ export class ActivitiesService {
|
||||
): Promise<ActivityDto> {
|
||||
const row = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId, ...activityAccessWhere(actor) },
|
||||
include,
|
||||
include: {
|
||||
...include,
|
||||
images: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
const dto = this.toDto(row, await this.ctx(organizationId));
|
||||
@@ -412,6 +574,12 @@ export class ActivitiesService {
|
||||
? [dto.assigneeId]
|
||||
: [userId],
|
||||
);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId, role: linkActor.role, organizationId },
|
||||
'activities',
|
||||
assigneeIds,
|
||||
);
|
||||
const assigneeId = assigneeIds[0] ?? userId;
|
||||
const initialStageId = emptyToNull(dto.stageId ?? null);
|
||||
const initialStage = initialStageId
|
||||
@@ -423,6 +591,7 @@ export class ActivitiesService {
|
||||
: requestedBoardId
|
||||
? await this.stages.resolveBoardId(organizationId, requestedBoardId)
|
||||
: null;
|
||||
if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor);
|
||||
const resolved = await this.types.resolve(organizationId, {
|
||||
typeDefId: dto.typeDefId,
|
||||
type: dto.type,
|
||||
@@ -432,6 +601,12 @@ export class ActivitiesService {
|
||||
resolved.fields,
|
||||
dto.fields,
|
||||
);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId, role: linkActor.role, organizationId },
|
||||
'activities',
|
||||
participantIds,
|
||||
);
|
||||
|
||||
const row = await this.prisma.activity.create({
|
||||
data: {
|
||||
@@ -460,6 +635,7 @@ export class ActivitiesService {
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
leadId: emptyToNull(dto.leadId ?? null),
|
||||
assigneeId,
|
||||
assignees: assigneeIds.length
|
||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||
@@ -543,6 +719,7 @@ export class ActivitiesService {
|
||||
select: { boardId: true },
|
||||
});
|
||||
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
|
||||
await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor);
|
||||
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
|
||||
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هموضعیت گروه جدید میرود
|
||||
data.boardId = nextBoardId;
|
||||
@@ -567,6 +744,13 @@ export class ActivitiesService {
|
||||
data.typeDefId = resolved.typeDefId;
|
||||
data.fields = fields as Prisma.InputJsonValue;
|
||||
data.participantIds = participantIds;
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'activities',
|
||||
participantIds,
|
||||
existing?.participantIds ?? [],
|
||||
);
|
||||
const before = new Set(existing?.participantIds ?? []);
|
||||
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
|
||||
}
|
||||
@@ -591,23 +775,49 @@ export class ActivitiesService {
|
||||
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 ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
|
||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
if (!assigneeIds.length) {
|
||||
throw new BadRequestException('حداقل یک مسئول لازم است');
|
||||
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
||||
const current = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { assigneeId: true, assignees: { select: { id: true } } },
|
||||
});
|
||||
const previous = current?.assignees.length
|
||||
? current.assignees.map((item) => item.id)
|
||||
: current?.assigneeId
|
||||
? [current.assigneeId]
|
||||
: [];
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
if (!assigneeIds.length) {
|
||||
throw new BadRequestException('حداقل یک مسئول لازم است');
|
||||
}
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'activities',
|
||||
assigneeIds,
|
||||
previous,
|
||||
);
|
||||
data.assigneeId = assigneeIds[0]!;
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
} else {
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'activities',
|
||||
[assigneeId],
|
||||
previous,
|
||||
);
|
||||
data.assigneeId = assigneeId;
|
||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||
else assigneeConnect = [];
|
||||
}
|
||||
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;
|
||||
@@ -701,7 +911,145 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const images = await this.prisma.activityImage.findMany({
|
||||
where: { activityId: id, organizationId },
|
||||
select: { storageKey: true, sizeBytes: true },
|
||||
});
|
||||
const bytes = images.reduce((sum, image) => sum + image.sizeBytes, 0);
|
||||
if (bytes) await this.storage.addLocal(organizationId, -bytes);
|
||||
await this.prisma.activity.delete({ where: { id } });
|
||||
for (const image of images) {
|
||||
await this.files.remove(image.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
async addImage(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
activityId: string,
|
||||
file: Express.Multer.File,
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityImageDto> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایلی ارسال نشده است');
|
||||
}
|
||||
const namedExt = path.extname(file.originalname || '').slice(1).toLowerCase();
|
||||
let mimeType = (file.mimetype || '').toLowerCase().split(';')[0]?.trim() || '';
|
||||
if (!mimeType || mimeType === 'application/octet-stream') {
|
||||
mimeType = ACTIVITY_EXT_MIME[namedExt] || mimeType;
|
||||
}
|
||||
const isVoice = mimeType.startsWith('audio/') || ACTIVITY_VOICE_MIME.has(mimeType);
|
||||
const isImage = ACTIVITY_IMAGE_MIME.has(mimeType);
|
||||
const isFile = ACTIVITY_FILE_MIME.has(mimeType);
|
||||
if (!isVoice && !isImage && !isFile) {
|
||||
throw new BadRequestException('این نوع فایل مجاز نیست.');
|
||||
}
|
||||
if (isVoice) {
|
||||
if (file.size > ACTIVITY_VOICE_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم صوت باید زیر ۲ مگابایت باشد');
|
||||
}
|
||||
const voiceCount = await this.prisma.activityImage.count({
|
||||
where: { activityId, mimeType: { startsWith: 'audio/' } },
|
||||
});
|
||||
if (voiceCount >= ACTIVITY_VOICE_MAX_COUNT) {
|
||||
throw new BadRequestException(`حداکثر ${ACTIVITY_VOICE_MAX_COUNT} صوت برای هر فعالیت مجاز است`);
|
||||
}
|
||||
} else if (isImage) {
|
||||
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
|
||||
}
|
||||
const count = await this.prisma.activityImage.count({
|
||||
where: { activityId, mimeType: { startsWith: 'image/' } },
|
||||
});
|
||||
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
|
||||
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
|
||||
}
|
||||
} else {
|
||||
if (file.size > ACTIVITY_FILE_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم فایل باید زیر ۱۰ مگابایت باشد');
|
||||
}
|
||||
const fileCount = await this.prisma.activityImage.count({
|
||||
where: {
|
||||
activityId,
|
||||
NOT: {
|
||||
OR: [{ mimeType: { startsWith: 'image/' } }, { mimeType: { startsWith: 'audio/' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (fileCount >= ACTIVITY_FILE_MAX_COUNT) {
|
||||
throw new BadRequestException(`حداکثر ${ACTIVITY_FILE_MAX_COUNT} فایل برای هر فعالیت مجاز است`);
|
||||
}
|
||||
}
|
||||
const fileName = sanitizeActivityImageName(file.originalname);
|
||||
const ext =
|
||||
path.extname(fileName).slice(0, 12) ||
|
||||
(mimeType === 'audio/ogg' ? '.ogg' : mimeType === 'audio/mp4' ? '.m4a' : isVoice ? '.webm' : '.jpg');
|
||||
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
|
||||
const storageKey = path
|
||||
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
await this.storage.assertCanAdd(organizationId, file.size);
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
try {
|
||||
const row = await this.prisma.activityImage.create({
|
||||
data: {
|
||||
id: imageId,
|
||||
organizationId,
|
||||
activityId,
|
||||
fileName,
|
||||
mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType,
|
||||
sizeBytes: file.size,
|
||||
storageKey,
|
||||
uploadedById: userId,
|
||||
},
|
||||
});
|
||||
await this.storage.addLocal(organizationId, file.size);
|
||||
return {
|
||||
id: row.id,
|
||||
fileName: row.fileName,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
await this.files.remove(storageKey);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async removeImage(
|
||||
organizationId: string,
|
||||
activityId: string,
|
||||
imageId: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const row = await this.prisma.activityImage.findFirst({
|
||||
where: { id: imageId, activityId, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
||||
await this.files.remove(row.storageKey);
|
||||
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
||||
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||
}
|
||||
|
||||
async openImage(
|
||||
organizationId: string,
|
||||
activityId: string,
|
||||
imageId: string,
|
||||
actor: ActivityActor,
|
||||
): Promise<{ stream: ReturnType<LocalFileStorage['openReadStream']>; fileName: string; mimeType: string }> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const row = await this.prisma.activityImage.findFirst({
|
||||
where: { id: imageId, activityId, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
||||
return {
|
||||
stream: this.files.openReadStream(row.storageKey),
|
||||
fileName: row.fileName,
|
||||
mimeType: row.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
private buildWhere(
|
||||
@@ -737,11 +1085,15 @@ export class ActivitiesService {
|
||||
: activityAccessWhere(actor);
|
||||
const facetAssignee = facets?.assigneeId?.trim();
|
||||
const facetTypeDef = facets?.typeDefId?.trim();
|
||||
const facetTitle = facets?.title?.trim();
|
||||
const facetCustomer = facets?.customer?.trim();
|
||||
const andParts: Prisma.ActivityWhereInput[] = [
|
||||
scope,
|
||||
...(facets?.type ? [{ type: facets.type }] : []),
|
||||
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
||||
...(facets?.status ? [{ status: facets.status }] : []),
|
||||
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
|
||||
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
|
||||
...(facetAssignee
|
||||
? [
|
||||
{
|
||||
@@ -788,15 +1140,18 @@ export class ActivitiesService {
|
||||
const fromDate = range?.from ? new Date(range.from) : null;
|
||||
const toDate = range?.to ? new Date(range.to) : null;
|
||||
if (fromDate || toDate) {
|
||||
return {
|
||||
...base,
|
||||
dueAt: {
|
||||
...(fromDate && !Number.isNaN(fromDate.getTime())
|
||||
? { gte: fromDate }
|
||||
: {}),
|
||||
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
|
||||
return this.withDue(
|
||||
{
|
||||
...base,
|
||||
dueAt: {
|
||||
...(fromDate && !Number.isNaN(fromDate.getTime())
|
||||
? { gte: fromDate }
|
||||
: {}),
|
||||
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
facets?.due,
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit status facet overrides the status implied by time tabs.
|
||||
@@ -810,28 +1165,62 @@ export class ActivitiesService {
|
||||
|
||||
switch (filter) {
|
||||
case 'today':
|
||||
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
|
||||
return this.withDue(
|
||||
{ ...base, ...impliedOpen, dueAt: { gte: start, lt: end } },
|
||||
facets?.due,
|
||||
);
|
||||
case 'overdue':
|
||||
// عقبافتاده ذاتاً یعنی باز و موعدگذشته
|
||||
return {
|
||||
...base,
|
||||
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
|
||||
dueAt: { lt: start },
|
||||
};
|
||||
return this.withDue(
|
||||
{
|
||||
...base,
|
||||
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
|
||||
dueAt: { lt: start },
|
||||
},
|
||||
facets?.due,
|
||||
);
|
||||
case 'upcoming':
|
||||
return {
|
||||
...base,
|
||||
...impliedOpen,
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
};
|
||||
return this.withDue(
|
||||
{
|
||||
...base,
|
||||
...impliedOpen,
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
},
|
||||
facets?.due,
|
||||
);
|
||||
case 'done':
|
||||
return { ...base, ...impliedDone };
|
||||
return this.withDue({ ...base, ...impliedDone }, facets?.due);
|
||||
case 'all':
|
||||
default:
|
||||
return base;
|
||||
return this.withDue(base, facets?.due);
|
||||
}
|
||||
}
|
||||
|
||||
/** نام شخص (چندکلمهای) یا نام شرکت */
|
||||
private customerWhere(term: string): Prisma.ActivityWhereInput {
|
||||
const parts = term.split(/\s+/).filter(Boolean);
|
||||
const person: Prisma.PersonWhereInput =
|
||||
parts.length <= 1
|
||||
? { OR: [{ firstName: ci(term) }, { lastName: ci(term) }] }
|
||||
: {
|
||||
AND: parts.map((part) => ({
|
||||
OR: [{ firstName: ci(part) }, { lastName: ci(part) }],
|
||||
})),
|
||||
};
|
||||
return {
|
||||
OR: [{ person }, { company: { name: ci(term) } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** وجود یا نبود زمان شروع، روی هر فیلتر زمانی */
|
||||
private withDue(
|
||||
where: Prisma.ActivityWhereInput,
|
||||
due?: 'set' | 'unset',
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (due !== 'set' && due !== 'unset') return where;
|
||||
return { AND: [where, due === 'set' ? { dueAt: { not: null } } : { dueAt: null }] };
|
||||
}
|
||||
|
||||
private async fireDone(
|
||||
organizationId: string,
|
||||
row: {
|
||||
@@ -903,11 +1292,13 @@ export class ActivitiesService {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
leadId?: string | null;
|
||||
},
|
||||
) {
|
||||
const personId = emptyToNull(dto.personId ?? null);
|
||||
const companyId = emptyToNull(dto.companyId ?? null);
|
||||
const dealId = emptyToNull(dto.dealId ?? null);
|
||||
const leadId = emptyToNull(dto.leadId ?? null);
|
||||
const scope = ownerWhere(actor);
|
||||
|
||||
if (personId) {
|
||||
@@ -931,6 +1322,13 @@ export class ActivitiesService {
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||
}
|
||||
if (leadId) {
|
||||
const lead = await this.prisma.lead.findFirst({
|
||||
where: { id: leadId, organizationId, ...leadsDeepWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!lead) throw new BadRequestException('لید انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyParticipants(
|
||||
@@ -971,6 +1369,13 @@ export class ActivitiesService {
|
||||
priority: row.priority,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
images: row.images?.map((image) => ({
|
||||
id: image.id,
|
||||
fileName: image.fileName,
|
||||
mimeType: image.mimeType,
|
||||
sizeBytes: image.sizeBytes,
|
||||
createdAt: image.createdAt.toISOString(),
|
||||
})),
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
durationMinutes: row.durationMinutes,
|
||||
reminderAt: row.reminderAt?.toISOString() ?? null,
|
||||
@@ -1042,6 +1447,12 @@ function typesMatching(term: string): ActivityDto['type'][] {
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeActivityImageName(raw: string): string {
|
||||
const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||
const trimmed = base.trim() || 'photo.jpg';
|
||||
return trimmed.slice(0, 180);
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityBoardDto,
|
||||
ActivityStageColor,
|
||||
@@ -34,8 +39,23 @@ type BoardRow = {
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
position: number;
|
||||
members: Array<{ userId: string }>;
|
||||
};
|
||||
|
||||
function toBoardDto(row: BoardRow): ActivityBoardDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: row.isDefault,
|
||||
position: row.position,
|
||||
memberIds: row.members.map((member) => member.userId),
|
||||
};
|
||||
}
|
||||
|
||||
function managesAllBoards(role: string) {
|
||||
return role === 'owner' || role === 'admin';
|
||||
}
|
||||
|
||||
const STAGE_SELECT = {
|
||||
id: true,
|
||||
boardId: true,
|
||||
@@ -50,6 +70,7 @@ const BOARD_SELECT = {
|
||||
name: true,
|
||||
isDefault: true,
|
||||
position: true,
|
||||
members: { select: { userId: true } },
|
||||
} satisfies Prisma.ActivityBoardSelect;
|
||||
|
||||
export type StageDefaults = Record<ActivityStatus, string | null>;
|
||||
@@ -109,14 +130,36 @@ export class ActivityStagesService {
|
||||
});
|
||||
}
|
||||
|
||||
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> {
|
||||
async listBoards(
|
||||
organizationId: string,
|
||||
actor?: { userId: string; role: string },
|
||||
): Promise<ActivityBoardDto[]> {
|
||||
await this.ensureDefaultBoard(organizationId);
|
||||
const rows = await this.prisma.activityBoard.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
return rows.map((b) => ({ ...b }));
|
||||
const boards = rows.map(toBoardDto);
|
||||
if (!actor || managesAllBoards(actor.role)) return boards;
|
||||
return boards.filter(
|
||||
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
|
||||
);
|
||||
}
|
||||
|
||||
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروهها را میبینند */
|
||||
async assertCanAccessBoard(
|
||||
organizationId: string,
|
||||
boardId: string,
|
||||
actor: { userId: string; role: string },
|
||||
) {
|
||||
if (managesAllBoards(actor.role)) return;
|
||||
const members = await this.prisma.activityBoardMember.findMany({
|
||||
where: { boardId, board: { organizationId } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
|
||||
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
|
||||
}
|
||||
|
||||
/** شناسهٔ گروه معتبر؛ خالی = گروه پیشفرض */
|
||||
@@ -131,7 +174,11 @@ export class ActivityStagesService {
|
||||
return board.id;
|
||||
}
|
||||
|
||||
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> {
|
||||
async createBoard(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
memberIds?: string[],
|
||||
): Promise<ActivityBoardDto> {
|
||||
await this.ensureDefaultBoard(organizationId);
|
||||
const max = await this.prisma.activityBoard.aggregate({
|
||||
where: { organizationId },
|
||||
@@ -147,7 +194,8 @@ export class ActivityStagesService {
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
await this.ensureBoardStages(organizationId, board.id);
|
||||
return { ...board };
|
||||
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
|
||||
return this.getBoardDto(organizationId, board.id);
|
||||
} catch (e) {
|
||||
throw this.mapBoardError(e);
|
||||
}
|
||||
@@ -156,21 +204,51 @@ export class ActivityStagesService {
|
||||
async updateBoard(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string },
|
||||
dto: { name?: string; memberIds?: string[] },
|
||||
): Promise<ActivityBoardDto> {
|
||||
await this.getBoard(organizationId, id);
|
||||
try {
|
||||
const board = await this.prisma.activityBoard.update({
|
||||
where: { id },
|
||||
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {},
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
return { ...board };
|
||||
if (dto.name != null) {
|
||||
await this.prisma.activityBoard.update({
|
||||
where: { id },
|
||||
data: { name: this.cleanBoardName(dto.name) },
|
||||
});
|
||||
}
|
||||
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
|
||||
return this.getBoardDto(organizationId, id);
|
||||
} catch (e) {
|
||||
throw this.mapBoardError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) {
|
||||
const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))];
|
||||
if (ids.length) {
|
||||
const found = await this.prisma.membership.findMany({
|
||||
where: { organizationId, userId: { in: ids }, isActive: true },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (found.length !== ids.length) {
|
||||
throw new BadRequestException('بعضی از اعضا در این محیط نیستند');
|
||||
}
|
||||
}
|
||||
await this.prisma.activityBoardMember.deleteMany({ where: { boardId } });
|
||||
if (ids.length) {
|
||||
await this.prisma.activityBoardMember.createMany({
|
||||
data: ids.map((userId) => ({ boardId, userId })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getBoardDto(organizationId: string, id: string): Promise<ActivityBoardDto> {
|
||||
const board = await this.prisma.activityBoard.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: BOARD_SELECT,
|
||||
});
|
||||
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
|
||||
return toBoardDto(board);
|
||||
}
|
||||
|
||||
/** فعالیتهای گروه حذفشده به گروه پیشفرض (ستون اولِ هموضعیت) منتقل میشوند */
|
||||
async removeBoard(organizationId: string, id: string): Promise<void> {
|
||||
const board = await this.getBoard(organizationId, id);
|
||||
@@ -213,8 +291,14 @@ export class ActivityStagesService {
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
|
||||
return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r));
|
||||
async list(
|
||||
organizationId: string,
|
||||
boardId?: string | null,
|
||||
actor?: { userId: string; role: string },
|
||||
): Promise<ActivityStageDto[]> {
|
||||
const resolved = await this.resolveBoardId(organizationId, boardId);
|
||||
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
|
||||
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||
|
||||
@@ -69,6 +69,23 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
/** نام شخص یا شرکتِ مشتری */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
customer?: string;
|
||||
|
||||
/** زمان شروع: set = دارد، unset = ندارد */
|
||||
@IsOptional()
|
||||
@IsIn(['set', 'unset'])
|
||||
due?: 'set' | 'unset';
|
||||
|
||||
/** نوع فعالیت (تعریفشده در سازمان) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -131,6 +148,11 @@ export class CreateActivityBoardDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateActivityBoardDto {
|
||||
@@ -139,6 +161,11 @@ export class UpdateActivityBoardDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export class ListActivityStagesQueryDto {
|
||||
@@ -337,6 +364,11 @@ export class CreateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -432,6 +464,11 @@ export class UpdateActivityDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
leadId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
VoicePersonDraft,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { leadsDeepWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
@@ -260,6 +261,9 @@ Rules:
|
||||
}
|
||||
|
||||
private leadWhere(actor: Actor) {
|
||||
if (actor.leadsAccess !== undefined) {
|
||||
return leadsDeepWhere(actor);
|
||||
}
|
||||
const scoped = forScope(actor, 'leads');
|
||||
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
||||
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
||||
|
||||
@@ -54,6 +54,7 @@ import { DeletionsModule } from './deletions/deletions.module';
|
||||
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||
import { BehaviorModule } from './behavior/behavior.module';
|
||||
import { SmsBillingModule } from './sms-billing/sms-billing.module';
|
||||
import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -65,6 +66,7 @@ import { SmsBillingModule } from './sms-billing/sms-billing.module';
|
||||
AiModule,
|
||||
PrismaModule,
|
||||
SmsBillingModule,
|
||||
CustomerGroupsModule,
|
||||
OutboundSmsModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
export const WORKDAY_ENDED_MESSAGE =
|
||||
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
||||
|
||||
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
|
||||
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
|
||||
|
||||
type RecordInput = {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
@@ -61,6 +64,18 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
|
||||
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد میشود. */
|
||||
async assertLoginAllowed(organizationId: string, userId: string, at = new Date()) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, userId, isActive: true },
|
||||
select: { role: true, logoutTime: true, blockLoginAfterLogout: true },
|
||||
});
|
||||
if (!membership || membership.role === 'owner' || !membership.blockLoginAfterLogout) return;
|
||||
if (loggedInAfterCutoff(at, membership.logoutTime)) {
|
||||
throw new ForbiddenException(LOGIN_BLOCKED_AFTER_HOURS_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
async recordLogout(actor: Actor) {
|
||||
if (actor.viewAsReadOnly) return { ok: true };
|
||||
const at = new Date();
|
||||
@@ -81,14 +96,20 @@ export class AttendanceService {
|
||||
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { logoutTime: true, role: true },
|
||||
select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
|
||||
});
|
||||
const logoutTime = membership?.logoutTime;
|
||||
if (!logoutTime || membership?.role === 'owner') return;
|
||||
const now = new Date();
|
||||
const cutoff = tehranCutoff(now, logoutTime);
|
||||
if (!cutoff || now.getTime() < cutoff.getTime()) return;
|
||||
if (actor.sessionStartedAt.getTime() >= cutoff.getTime()) return;
|
||||
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
|
||||
if (
|
||||
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
|
||||
!membership?.blockLoginAfterLogout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.write({
|
||||
organizationId: actor.organizationId,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
@@ -42,9 +43,9 @@ class ListAuditQueryDto {
|
||||
}
|
||||
|
||||
@Controller('audit-logs')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('audit_logs')
|
||||
@Roles('owner', 'admin')
|
||||
@RequirePermission('audit', { reads: true })
|
||||
export class AuditLogsController {
|
||||
constructor(
|
||||
private readonly auditLogs: AuditLogsService,
|
||||
@@ -52,6 +53,7 @@ export class AuditLogsController {
|
||||
) {}
|
||||
|
||||
@Get('attendance')
|
||||
@Roles('owner', 'admin')
|
||||
attendanceList(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListAuditQueryDto,
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
LoginResponse,
|
||||
LoginResult,
|
||||
OrgModuleKey,
|
||||
StorageUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -26,6 +27,8 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
||||
import { AttendanceService } from '../attendance/attendance.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
@@ -111,6 +114,8 @@ export class AuthService {
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly storageQuota: StorageQuotaService,
|
||||
private readonly adminAlerts: PlatformAdminAlertService,
|
||||
private readonly attendance: AttendanceService,
|
||||
) {}
|
||||
|
||||
@@ -280,6 +285,12 @@ export class AuthService {
|
||||
where: { id: organization.id },
|
||||
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||
});
|
||||
await this.adminAlerts.notifyNewAccount({
|
||||
source: 'signup',
|
||||
organizationName: organization.name,
|
||||
ownerName: owner.fullName,
|
||||
mobile: owner.mobile,
|
||||
});
|
||||
|
||||
await this.prisma.authChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
@@ -315,11 +326,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
|
||||
if (!ok && !viaMaster) {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
|
||||
return this.finishLogin(user.id);
|
||||
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
|
||||
}
|
||||
|
||||
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||
@@ -613,11 +625,15 @@ export class AuthService {
|
||||
const storageKey = path
|
||||
.join(organizationId, 'users', userId, `avatar.${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
|
||||
const delta = file.size - prevBytes;
|
||||
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
if (prevKey && prevKey !== storageKey) {
|
||||
await this.files.remove(prevKey);
|
||||
}
|
||||
await this.writeAvatarKey(userId, storageKey);
|
||||
if (delta) await this.storageQuota.addLocal(organizationId, delta);
|
||||
return this.toAuthUserDto(existing, organizationId);
|
||||
}
|
||||
|
||||
@@ -627,7 +643,11 @@ export class AuthService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const prevKey = await this.readAvatarKey(userId);
|
||||
if (prevKey) await this.files.remove(prevKey);
|
||||
if (prevKey) {
|
||||
const bytes = await this.files.sizeOf(prevKey);
|
||||
await this.files.remove(prevKey);
|
||||
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
|
||||
}
|
||||
await this.writeAvatarKey(userId, null);
|
||||
return this.toAuthUserDto(existing, organizationId);
|
||||
}
|
||||
@@ -673,16 +693,25 @@ export class AuthService {
|
||||
selectionToken: string,
|
||||
organizationId: string,
|
||||
): Promise<LoginResponse> {
|
||||
let payload: { sub?: string; typ?: string };
|
||||
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
|
||||
try {
|
||||
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
|
||||
payload = this.jwt.verify<{
|
||||
sub?: string;
|
||||
typ?: string;
|
||||
pwd?: boolean;
|
||||
master?: boolean;
|
||||
}>(selectionToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
||||
}
|
||||
if (payload.typ !== 'org-select' || !payload.sub) {
|
||||
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
|
||||
}
|
||||
return this.issueLogin(payload.sub, organizationId);
|
||||
return this.issueLogin(payload.sub, organizationId, {
|
||||
fromPassword: payload.pwd === true,
|
||||
viaMaster: payload.master === true,
|
||||
skipLastLogin: payload.master === true,
|
||||
});
|
||||
}
|
||||
|
||||
async switchOrganization(
|
||||
@@ -692,30 +721,72 @@ export class AuthService {
|
||||
return this.issueLogin(userId, organizationId);
|
||||
}
|
||||
|
||||
private async finishLogin(userId: string): Promise<LoginResult> {
|
||||
private async finishLogin(
|
||||
userId: string,
|
||||
opts?: { fromPassword?: boolean; viaMaster?: boolean },
|
||||
): Promise<LoginResult> {
|
||||
const rows = await this.memberships.listActive(userId);
|
||||
if (!rows.length) {
|
||||
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
|
||||
}
|
||||
if (rows.length === 1) {
|
||||
return this.issueLogin(userId, rows[0]!.organization.id);
|
||||
const usable =
|
||||
opts?.fromPassword && !opts.viaMaster
|
||||
? rows.filter((row) => !row.loginOtpOnly)
|
||||
: rows;
|
||||
if (!usable.length) {
|
||||
throw new UnauthorizedException(
|
||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||
);
|
||||
}
|
||||
if (usable.length === 1) {
|
||||
return this.issueLogin(userId, usable[0]!.organization.id, {
|
||||
fromPassword: opts?.fromPassword,
|
||||
viaMaster: opts?.viaMaster,
|
||||
skipLastLogin: opts?.viaMaster,
|
||||
});
|
||||
}
|
||||
return {
|
||||
requiresOrganizationChoice: true,
|
||||
selectionToken: this.jwt.sign(
|
||||
{ sub: userId, typ: 'org-select' },
|
||||
{
|
||||
sub: userId,
|
||||
typ: 'org-select',
|
||||
...(opts?.fromPassword ? { pwd: true } : {}),
|
||||
...(opts?.viaMaster ? { master: true } : {}),
|
||||
},
|
||||
{ expiresIn: '10m' },
|
||||
),
|
||||
organizations: this.memberships.toChoices(rows),
|
||||
organizations: this.memberships.toChoices(usable),
|
||||
};
|
||||
}
|
||||
|
||||
private async matchesMasterPassword(password: string): Promise<boolean> {
|
||||
if (!password) return false;
|
||||
const row = await this.prisma.platformMasterPassword.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
if (!row?.passwordHash) return false;
|
||||
return bcrypt.compare(password, row.passwordHash);
|
||||
}
|
||||
|
||||
private async issueLogin(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
opts?: { skipLastLogin?: boolean },
|
||||
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
|
||||
): Promise<LoginResponse> {
|
||||
const membership = await this.memberships.requireActive(userId, organizationId);
|
||||
if (opts?.fromPassword && !opts.viaMaster) {
|
||||
const gate = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
select: { loginOtpOnly: true },
|
||||
});
|
||||
if (gate?.loginOtpOnly) {
|
||||
throw new UnauthorizedException(
|
||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
@@ -723,6 +794,9 @@ export class AuthService {
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
organizationId,
|
||||
);
|
||||
if (!opts?.skipLastLogin) {
|
||||
await this.attendance.assertLoginAllowed(organizationId, userId);
|
||||
}
|
||||
await this.memberships.syncUserLastOrg(
|
||||
userId,
|
||||
organizationId,
|
||||
@@ -786,6 +860,12 @@ export class AuthService {
|
||||
canOpenTeamSettings: true,
|
||||
}
|
||||
: undefined;
|
||||
let storage: StorageUsageDto | undefined;
|
||||
try {
|
||||
storage = await this.storageQuota.usageForOrganization(organizationId);
|
||||
} catch {
|
||||
storage = undefined;
|
||||
}
|
||||
return this.toAuthUser({
|
||||
...user,
|
||||
role,
|
||||
@@ -796,6 +876,7 @@ export class AuthService {
|
||||
membership?.organization.enabledModules,
|
||||
),
|
||||
subscription: sub,
|
||||
storage,
|
||||
isOrgOwner: role === 'owner',
|
||||
priorArchive: this.priorArchiveFor(membership),
|
||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||
@@ -906,6 +987,7 @@ export class AuthService {
|
||||
avatarStorageKey?: string | null;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
storage?: StorageUsageDto | null;
|
||||
isOrgOwner?: boolean;
|
||||
priorArchive?: AuthUserDto['priorArchive'];
|
||||
viewAs?: AuthUserDto['viewAs'];
|
||||
@@ -922,6 +1004,7 @@ export class AuthService {
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||
...(user.storage ? { storage: user.storage } : {}),
|
||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||
|
||||
@@ -4,6 +4,10 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { DataVisibility } from '@mento/shared';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import {
|
||||
resolveAllSectionAccess,
|
||||
sectionNeedsTeamPeers,
|
||||
} from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
normalizeDataVisibility,
|
||||
@@ -86,8 +90,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
role: true,
|
||||
dataVisibility: true,
|
||||
dataVisibilityScopes: true,
|
||||
dataAccessOverride: true,
|
||||
priorArchiveEnabled: true,
|
||||
priorArchiveLens: true,
|
||||
orgRole: { select: { dataAccessPolicy: true } },
|
||||
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
|
||||
},
|
||||
});
|
||||
@@ -118,6 +124,27 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
}
|
||||
|
||||
const deepAccess = resolveAllSectionAccess({
|
||||
role: membership.role,
|
||||
rolePolicy: membership.orgRole?.dataAccessPolicy,
|
||||
override: membership.dataAccessOverride,
|
||||
legacyVisibility: dataVisibility,
|
||||
legacyScopes: scopeLevels,
|
||||
});
|
||||
const needsTeamPeers =
|
||||
!elevatedRole &&
|
||||
(sectionNeedsTeamPeers(deepAccess.customers) ||
|
||||
sectionNeedsTeamPeers(deepAccess.leads) ||
|
||||
sectionNeedsTeamPeers(deepAccess.sales) ||
|
||||
sectionNeedsTeamPeers(deepAccess.activities) ||
|
||||
sectionNeedsTeamPeers(deepAccess.notes) ||
|
||||
sectionNeedsTeamPeers(deepAccess.routines) ||
|
||||
sectionNeedsTeamPeers(deepAccess.projects) ||
|
||||
sectionNeedsTeamPeers(deepAccess.tickets));
|
||||
if (needsTeamPeers) {
|
||||
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
|
||||
}
|
||||
|
||||
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||
const priorGranted =
|
||||
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
||||
@@ -130,6 +157,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
dataVisibility,
|
||||
visibleUserIds,
|
||||
scopes,
|
||||
customersAccess: deepAccess.customers,
|
||||
leadsAccess: deepAccess.leads,
|
||||
salesAccess: deepAccess.sales,
|
||||
activitiesAccess: deepAccess.activities,
|
||||
notesAccess: deepAccess.notes,
|
||||
routinesAccess: deepAccess.routines,
|
||||
projectsAccess: deepAccess.projects,
|
||||
ticketsAccess: deepAccess.tickets,
|
||||
teamPeerIds: teamIds ?? (elevatedRole ? null : [user.id]),
|
||||
priorLens: priorGranted ? priorLens : 'off',
|
||||
priorCutoff:
|
||||
priorGranted && membership.organization.priorArchiveEnabledAt
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { RolePermissionKey } from '@mento/shared';
|
||||
import { ROLE_PERMISSION_ITEMS } from '@mento/shared';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const SECTION_PERMISSION_KEY = 'sectionPermission';
|
||||
|
||||
type SectionPermissionMeta = {
|
||||
key: RolePermissionKey;
|
||||
/** true: خواندن هم مجوز میخواهد. false: فقط مدیریت (ثبت/ویرایش/حذف/ورود/خروجی) */
|
||||
reads: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* مجوز «بخش» از نقش سازمانی. مالک و مدیر همیشه مجازند.
|
||||
* پیشفرض فقط مدیریت را میبندد تا انتخاب رکورد در فرمهای بخشهای دیگر کار کند.
|
||||
*/
|
||||
export const RequirePermission = (key: RolePermissionKey, opts?: { reads?: boolean }) =>
|
||||
SetMetadata(SECTION_PERMISSION_KEY, { key, reads: opts?.reads ?? false } satisfies SectionPermissionMeta);
|
||||
|
||||
function isReadRequest(method: string, url: string): boolean {
|
||||
if (method !== 'GET' && method !== 'HEAD') return false;
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
||||
return !path.endsWith('/export');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SectionPermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const meta = this.reflector.getAllAndOverride<SectionPermissionMeta | undefined>(
|
||||
SECTION_PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!meta) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const actor = req.user;
|
||||
const label = ROLE_PERMISSION_ITEMS.find((item) => item.key === meta.key)?.label ?? meta.key;
|
||||
if (!actor) throw new ForbiddenException(`دسترسی به «${label}» مجاز نیست.`);
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
if (!meta.reads && isReadRequest(method, req.originalUrl || req.url || '')) return true;
|
||||
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = membership?.orgRole?.permissions ?? [];
|
||||
if (!permissions.includes(meta.key)) {
|
||||
throw new ForbiddenException(`دسترسی به «${label}» برای نقش شما مجاز نیست.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,19 @@ export type TemplateVars = {
|
||||
formValues?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* مسئول سرنخ فقط از اولین فعالیتی که مسئول دارد پر میشود.
|
||||
* اگر قبلاً گرفته شده یا این فعالیت مسئول ندارد، null برمیگردد.
|
||||
*/
|
||||
export function claimLeadAssignee(
|
||||
alreadyClaimed: boolean,
|
||||
activityAssigneeId: string | null | undefined,
|
||||
): string | null {
|
||||
if (alreadyClaimed) return null;
|
||||
const id = activityAssigneeId?.trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
/** counter یکواحد افزایشیافته از AutomationCursor است (از ۱ شروع میشود) */
|
||||
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
|
||||
const ids = [...new Set(memberIds.filter(Boolean))].sort();
|
||||
|
||||
@@ -34,6 +34,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import {
|
||||
claimLeadAssignee,
|
||||
formatFormAnswers,
|
||||
pickRoundRobin,
|
||||
renderAutomationTemplate,
|
||||
@@ -59,6 +60,12 @@ export type AutomationContext = {
|
||||
formName?: string | null;
|
||||
formValues?: Record<string, unknown> | null;
|
||||
formFields?: FormFieldLabel[] | null;
|
||||
/**
|
||||
* فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا میشود.
|
||||
* داخل همان اجرا یکبار ست میشود تا فعالیت بعدی مسئول را عوض نکند.
|
||||
*/
|
||||
syncLeadAssignee?: boolean;
|
||||
leadAssigneeSynced?: boolean;
|
||||
};
|
||||
|
||||
type AutomationFacts = {
|
||||
@@ -406,6 +413,54 @@ export class AutomationsService {
|
||||
entityId: activity.id,
|
||||
});
|
||||
}
|
||||
await this.syncLeadAssigneeFromActivity(ctx, type === 'call' ? assigneeId : null);
|
||||
}
|
||||
|
||||
/** سرنخ تازهای که فرم برایش مسئول نوبتی ندارد، مسئول اولین تماس را میگیرد. */
|
||||
private async syncLeadAssigneeFromActivity(
|
||||
ctx: AutomationContext,
|
||||
activityAssigneeId: string | null,
|
||||
) {
|
||||
const assigneeId = claimLeadAssignee(ctx.leadAssigneeSynced === true, activityAssigneeId);
|
||||
if (!ctx.syncLeadAssignee || !assigneeId || !ctx.leadId) return;
|
||||
|
||||
const existing = await this.prisma.lead.findFirst({
|
||||
where: {
|
||||
id: ctx.leadId,
|
||||
organizationId: ctx.organizationId,
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { assigneeId: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
ctx.leadAssigneeSynced = true;
|
||||
ctx.assigneeId = assigneeId;
|
||||
if (existing.assigneeId === assigneeId) return;
|
||||
|
||||
await this.prisma.lead.updateMany({
|
||||
where: {
|
||||
id: ctx.leadId,
|
||||
organizationId: ctx.organizationId,
|
||||
archivedAt: null,
|
||||
},
|
||||
data: { assigneeId },
|
||||
});
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: assigneeId },
|
||||
select: { fullName: true },
|
||||
});
|
||||
const name = user?.fullName?.trim() || 'کارشناس';
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: ctx.organizationId,
|
||||
leadId: ctx.leadId,
|
||||
type: 'assigned',
|
||||
title: `به «${name}» اختصاص داده شد`,
|
||||
body: 'مسئول سرنخ همان مسئول اولین تماس اتوماسیون شد.',
|
||||
meta: { assigneeId, source: 'automation' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveAssignee(
|
||||
@@ -523,7 +578,7 @@ export class AutomationsService {
|
||||
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
|
||||
const wantApp = channel === 'app' || channel === 'both';
|
||||
const wantSms = channel === 'sms' || channel === 'both';
|
||||
const smsText = (config.body?.trim() || title).slice(0, 160);
|
||||
const smsText = (config.body?.trim() || title).trim();
|
||||
const link = entityLink(ctx);
|
||||
|
||||
if (config.notifyTarget === 'person') {
|
||||
@@ -537,9 +592,16 @@ export class AutomationsService {
|
||||
select: { mobile: true, phone: true },
|
||||
})
|
||||
: null;
|
||||
const leadId = ctx.leadId || (ctx.entityType === 'lead' ? ctx.entityId : null);
|
||||
const lead = !person?.mobile && !person?.phone && leadId
|
||||
? await this.prisma.lead.findFirst({
|
||||
where: { id: leadId, organizationId: ctx.organizationId },
|
||||
select: { mobile: true, phone: true },
|
||||
})
|
||||
: null;
|
||||
await this.sendAutomationSms(
|
||||
ctx.organizationId,
|
||||
person?.mobile || person?.phone,
|
||||
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
|
||||
smsText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingExpiryService {
|
||||
private readonly logger = new Logger(BillingExpiryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly alerts: PlatformAdminAlertService,
|
||||
) {}
|
||||
|
||||
@Cron('15 * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async handleHourly(): Promise<void> {
|
||||
await this.notifyEnded();
|
||||
}
|
||||
|
||||
async notifyEnded(): Promise<number> {
|
||||
const rows = await this.prisma.billingAccount.findMany({
|
||||
where: {
|
||||
expiryNotifiedAt: null,
|
||||
planCode: { not: 'lifetime' },
|
||||
status: { not: 'suspended' },
|
||||
expiresAt: { lte: new Date() },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await this.alerts.notifySubscriptionEnded(row.id);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`expiry notify failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rows.length) this.logger.log(`subscription expiry checked: ${rows.length}`);
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { BillingExpiryService } from './billing-expiry.service';
|
||||
import { BillingService } from './billing.service';
|
||||
import { OrgModuleGuard } from './org-module.guard';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
import { StorageQuotaService } from './storage-quota.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [BillingService, OrgModuleGuard],
|
||||
exports: [BillingService, OrgModuleGuard],
|
||||
imports: [StorageModule],
|
||||
providers: [
|
||||
BillingService,
|
||||
BillingExpiryService,
|
||||
PlatformAdminAlertService,
|
||||
OrgModuleGuard,
|
||||
StorageQuotaService,
|
||||
],
|
||||
exports: [
|
||||
BillingService,
|
||||
BillingExpiryService,
|
||||
PlatformAdminAlertService,
|
||||
OrgModuleGuard,
|
||||
StorageQuotaService,
|
||||
],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
BillingAccountStatus,
|
||||
BillingPlanCode,
|
||||
OrgModuleKey,
|
||||
OwnerSeatUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
isOrgModuleKey,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
|
||||
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||
const ms = expiresAt.getTime() - now.getTime();
|
||||
@@ -50,7 +52,10 @@ export function toSummary(row: {
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly adminAlerts: PlatformAdminAlertService,
|
||||
) {}
|
||||
|
||||
toDto(row: {
|
||||
id: string;
|
||||
@@ -60,6 +65,7 @@ export class BillingService {
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
maxUsers?: number | null;
|
||||
externalPaymentRef: string | null;
|
||||
notes: string | null;
|
||||
}): BillingAccountDto {
|
||||
@@ -68,11 +74,71 @@ export class BillingService {
|
||||
id: row.id,
|
||||
ownerUserId: row.ownerUserId,
|
||||
...summary,
|
||||
maxUsers: row.maxUsers ?? null,
|
||||
externalPaymentRef: row.externalPaymentRef,
|
||||
notes: row.notes,
|
||||
};
|
||||
}
|
||||
|
||||
async seatUsage(ownerUserId: string): Promise<OwnerSeatUsageDto> {
|
||||
const account = await this.getByOwnerUserId(ownerUserId);
|
||||
const orgIds = await this.ownedOrganizationIds(ownerUserId);
|
||||
const usedUsers = await this.countActiveUsers(orgIds);
|
||||
return { usedUsers, maxUsers: account?.maxUsers ?? null };
|
||||
}
|
||||
|
||||
async setMaxUsers(ownerUserId: string, maxUsers: number): Promise<OwnerSeatUsageDto> {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { ownerUserId },
|
||||
data: { maxUsers },
|
||||
});
|
||||
return this.seatUsage(ownerUserId);
|
||||
}
|
||||
|
||||
/** کاربر جدید فقط وقتی سقف اشتراک جا دارد. کسی که از قبل در محیطهای همین مالک فعال است صندلی تازه نمیگیرد. */
|
||||
async assertSeatAvailable(organizationId: string, userId: string | null): Promise<void> {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!owner) return;
|
||||
const account = await this.getByOwnerUserId(owner.userId);
|
||||
if (!account || account.maxUsers == null) return;
|
||||
const orgIds = await this.ownedOrganizationIds(owner.userId);
|
||||
if (userId) {
|
||||
const already = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId: { in: orgIds }, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (already) return;
|
||||
}
|
||||
const used = await this.countActiveUsers(orgIds);
|
||||
if (used >= account.maxUsers) {
|
||||
throw new ForbiddenException(
|
||||
`سقف کاربر این اشتراک ${account.maxUsers} نفر است. برای افزودن کاربر باید سقف اشتراک بیشتر شود.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ownedOrganizationIds(ownerUserId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.membership.findMany({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
return rows.map((row) => row.organizationId);
|
||||
}
|
||||
|
||||
private async countActiveUsers(organizationIds: string[]): Promise<number> {
|
||||
if (!organizationIds.length) return 0;
|
||||
const rows = await this.prisma.membership.findMany({
|
||||
where: { organizationId: { in: organizationIds }, isActive: true },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
});
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async getByOwnerUserId(ownerUserId: string) {
|
||||
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||
}
|
||||
@@ -93,6 +159,7 @@ export class BillingService {
|
||||
startsAt: now,
|
||||
expiresAt,
|
||||
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||
maxUsers: 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -151,6 +218,7 @@ export class BillingService {
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
}
|
||||
await this.adminAlerts.notifySubscriptionEnded(account.id);
|
||||
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||
}
|
||||
return toSummary(account);
|
||||
@@ -220,6 +288,10 @@ export class BillingService {
|
||||
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||
|
||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||
const expiresChanged =
|
||||
!existing || existing.expiresAt.getTime() !== expiresAt.getTime();
|
||||
const expiryNotifiedAt =
|
||||
status === 'active' || expiresChanged ? null : existing?.expiryNotifiedAt ?? null;
|
||||
const row = existing
|
||||
? await this.prisma.billingAccount.update({
|
||||
where: { id: existing.id },
|
||||
@@ -229,6 +301,8 @@ export class BillingService {
|
||||
startsAt: existing.startsAt ?? now,
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
expiryNotifiedAt,
|
||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||
},
|
||||
})
|
||||
@@ -241,8 +315,13 @@ export class BillingService {
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
notes: dto.notes ?? null,
|
||||
expiryNotifiedAt,
|
||||
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
|
||||
},
|
||||
});
|
||||
if (status === 'expired' && !expiryNotifiedAt) {
|
||||
await this.adminAlerts.notifySubscriptionEnded(row.id);
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { isPermanentBillingPlan } from '@mento/shared';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
newAccountSms,
|
||||
subscriptionEndedSms,
|
||||
type NewAccountSource,
|
||||
} from './platform-admin-alert';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformAdminAlertService {
|
||||
private readonly logger = new Logger(PlatformAdminAlertService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
) {}
|
||||
|
||||
/** ساخت حساب جدید؛ خطای پیامک جلوی ثبتنام یا ساخت محیط را نمیگیرد. */
|
||||
async notifyNewAccount(input: {
|
||||
source: NewAccountSource;
|
||||
organizationName: string;
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const delivered = await this.sendToAdmins(newAccountSms(input));
|
||||
if (!delivered) {
|
||||
this.logger.warn(`new account SMS was not delivered (${input.source})`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`new account SMS failed: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* یکبار هنگام تمام شدن اشتراک. اگر ارسال نشود پرچم خالی میماند تا بعداً دوباره تلاش شود.
|
||||
*/
|
||||
async notifySubscriptionEnded(billingAccountId: string): Promise<void> {
|
||||
const row = await this.prisma.billingAccount.findUnique({
|
||||
where: { id: billingAccountId },
|
||||
select: {
|
||||
id: true,
|
||||
planCode: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
expiryNotifiedAt: true,
|
||||
owner: {
|
||||
select: {
|
||||
fullName: true,
|
||||
mobile: true,
|
||||
memberships: {
|
||||
where: { role: 'owner', isActive: true },
|
||||
select: { organization: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row || row.expiryNotifiedAt) return;
|
||||
if (row.status === 'suspended') return;
|
||||
if (isPermanentBillingPlan(row.planCode)) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: new Date() },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.expiresAt.getTime() > Date.now() && row.status !== 'expired') return;
|
||||
|
||||
const claimed = await this.prisma.billingAccount.updateMany({
|
||||
where: { id: row.id, expiryNotifiedAt: null },
|
||||
data: { status: 'expired', expiryNotifiedAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) return;
|
||||
|
||||
try {
|
||||
const delivered = await this.sendToAdmins(
|
||||
subscriptionEndedSms({
|
||||
organizationNames: row.owner.memberships.map((item) => item.organization.name),
|
||||
ownerName: row.owner.fullName,
|
||||
mobile: row.owner.mobile,
|
||||
planCode: row.planCode,
|
||||
expiresAt: row.expiresAt,
|
||||
}),
|
||||
);
|
||||
if (!delivered) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: null },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`subscription SMS failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
|
||||
);
|
||||
await this.prisma.billingAccount
|
||||
.update({
|
||||
where: { id: row.id },
|
||||
data: { expiryNotifiedAt: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** true یعنی حداقل یک پیامک رفت، یا مدیری با موبایل فعال وجود نداشت. */
|
||||
private async sendToAdmins(text: string): Promise<boolean> {
|
||||
const admins = await this.prisma.platformAdmin.findMany({
|
||||
where: { isActive: true, mobile: { not: null } },
|
||||
select: { mobile: true },
|
||||
});
|
||||
const mobiles = [
|
||||
...new Set(
|
||||
admins
|
||||
.map((admin) => admin.mobile?.trim() ?? '')
|
||||
.filter((mobile) => mobile.length > 0),
|
||||
),
|
||||
];
|
||||
if (!mobiles.length) {
|
||||
this.logger.warn('platform admin SMS skipped: no active admin mobile');
|
||||
return true;
|
||||
}
|
||||
|
||||
let delivered = false;
|
||||
for (const mobile of mobiles) {
|
||||
try {
|
||||
const result = await this.sms.send({
|
||||
phone: mobile,
|
||||
text,
|
||||
source: 'platform-admin',
|
||||
});
|
||||
if (result.ok) delivered = true;
|
||||
else {
|
||||
this.logger.warn(
|
||||
`platform admin SMS failed to=…${mobile.slice(-4)} reason=${result.reason ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`platform admin SMS failed to=…${mobile.slice(-4)}: ${
|
||||
error instanceof Error ? error.message : 'unknown'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { BILLING_PLAN_LABELS, type BillingPlanCode } from '@mento/shared';
|
||||
import { formatTehranJalali } from '../common/jalali';
|
||||
|
||||
export type NewAccountSource = 'signup' | 'admin';
|
||||
|
||||
export function newAccountSms(input: {
|
||||
source: NewAccountSource;
|
||||
organizationName: string;
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
}): string {
|
||||
const via = input.source === 'signup' ? 'ثبتنام' : 'پنل ادمین';
|
||||
return [
|
||||
'منتکت',
|
||||
`حساب جدید (${via})`,
|
||||
`محیط: ${input.organizationName.trim() || '—'}`,
|
||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function subscriptionEndedSms(input: {
|
||||
organizationNames: string[];
|
||||
ownerName: string;
|
||||
mobile: string | null;
|
||||
planCode: string;
|
||||
expiresAt: Date;
|
||||
}): string {
|
||||
const names = input.organizationNames.map((name) => name.trim()).filter(Boolean);
|
||||
const orgs = names.slice(0, 3).join('، ') || '—';
|
||||
const extra = names.length > 3 ? ` و ${names.length - 3} محیط دیگر` : '';
|
||||
const label =
|
||||
BILLING_PLAN_LABELS[input.planCode as BillingPlanCode] ?? input.planCode;
|
||||
return [
|
||||
'منتکت',
|
||||
'پایان اشتراک',
|
||||
`محیط: ${orgs}${extra}`,
|
||||
`مالک: ${input.ownerName.trim() || '—'}`,
|
||||
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
|
||||
`پلن: ${label}`,
|
||||
`تاریخ: ${formatTehranJalali(input.expiresAt)}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
|
||||
import type { StorageUsageDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
|
||||
function asNumber(value: bigint | number | null | undefined): number {
|
||||
if (value == null) return 0;
|
||||
const n = typeof value === 'bigint' ? Number(value) : value;
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
function levelOf(used: number, quota: number | null): StorageUsageDto['level'] {
|
||||
if (quota == null) return 'unlimited';
|
||||
if (quota <= 0 || used >= quota) return 'full';
|
||||
if (used >= quota * 0.8) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageQuotaService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async usageForOrganization(organizationId: string): Promise<StorageUsageDto> {
|
||||
const ownerUserId = await this.ownerUserId(organizationId);
|
||||
if (!ownerUserId) return this.empty();
|
||||
return this.usageForOwner(ownerUserId);
|
||||
}
|
||||
|
||||
async usageForOwner(ownerUserId: string, refresh = false): Promise<StorageUsageDto> {
|
||||
const orgs = await this.ownerOrgs(ownerUserId);
|
||||
if (refresh) {
|
||||
await Promise.all(orgs.map((org) => this.measureLocal(org.id)));
|
||||
} else {
|
||||
await Promise.all(
|
||||
orgs.filter((org) => !org.fileBytesSyncedAt).map((org) => this.measureLocal(org.id)),
|
||||
);
|
||||
}
|
||||
const fresh = refresh || orgs.some((org) => !org.fileBytesSyncedAt)
|
||||
? await this.ownerOrgs(ownerUserId)
|
||||
: orgs;
|
||||
const account = await this.prisma.billingAccount.findUnique({
|
||||
where: { ownerUserId },
|
||||
select: { storageQuotaBytes: true },
|
||||
});
|
||||
const quota = account?.storageQuotaBytes == null ? null : asNumber(account.storageQuotaBytes);
|
||||
const byOrganization = fresh.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
usedBytes: asNumber(org.fileBytes) + asNumber(org.chatFileBytes),
|
||||
}));
|
||||
const used = byOrganization.reduce((sum, org) => sum + org.usedBytes, 0);
|
||||
const percent = quota == null || quota <= 0 ? null : Math.min(100, Math.round((used / quota) * 100));
|
||||
return {
|
||||
usedBytes: used,
|
||||
quotaBytes: quota,
|
||||
percent,
|
||||
level: levelOf(used, quota),
|
||||
byOrganization,
|
||||
};
|
||||
}
|
||||
|
||||
async assertCanAdd(organizationId: string, bytes: number): Promise<void> {
|
||||
if (bytes <= 0) return;
|
||||
const ownerUserId = await this.ownerUserId(organizationId);
|
||||
if (!ownerUserId) return;
|
||||
const usage = await this.usageForOwner(ownerUserId);
|
||||
if (usage.quotaBytes == null) return;
|
||||
if (usage.usedBytes + bytes > usage.quotaBytes) {
|
||||
throw new PayloadTooLargeException(
|
||||
'حجم فایل این اشتراک پر شده است. تا شارژ حجم، آپلود جدید ممکن نیست.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async addLocal(organizationId: string, delta: number): Promise<void> {
|
||||
await this.add(organizationId, 'fileBytes', delta);
|
||||
}
|
||||
|
||||
async addChat(organizationId: string, bytes: number): Promise<StorageUsageDto> {
|
||||
if (bytes > 0) await this.assertCanAdd(organizationId, bytes);
|
||||
if (bytes > 0) await this.add(organizationId, 'chatFileBytes', bytes);
|
||||
return this.usageForOrganization(organizationId);
|
||||
}
|
||||
|
||||
async setQuotaGb(ownerUserId: string, quotaGb: number | null): Promise<StorageUsageDto> {
|
||||
const bytes = quotaGb == null ? null : BigInt(Math.round(quotaGb * GIB));
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { ownerUserId },
|
||||
data: { storageQuotaBytes: bytes },
|
||||
});
|
||||
return this.usageForOwner(ownerUserId, true);
|
||||
}
|
||||
|
||||
private async add(
|
||||
organizationId: string,
|
||||
field: 'fileBytes' | 'chatFileBytes',
|
||||
delta: number,
|
||||
): Promise<void> {
|
||||
if (!delta) return;
|
||||
const row = await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { [field]: { increment: BigInt(Math.round(delta)) } },
|
||||
select: { fileBytes: true, chatFileBytes: true },
|
||||
});
|
||||
const value = field === 'fileBytes' ? row.fileBytes : row.chatFileBytes;
|
||||
if (value < 0n) {
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { [field]: 0n },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ownerUserId(organizationId: string): Promise<string | null> {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
return owner?.userId ?? null;
|
||||
}
|
||||
|
||||
private ownerOrgs(ownerUserId: string) {
|
||||
return this.prisma.membership.findMany({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
select: {
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fileBytes: true,
|
||||
chatFileBytes: true,
|
||||
fileBytesSyncedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}).then((rows) => rows.map((row) => row.organization));
|
||||
}
|
||||
|
||||
private async measureLocal(organizationId: string): Promise<void> {
|
||||
const [imageSum, noteSum, treasury, leads, forms, users] = await Promise.all([
|
||||
this.prisma.activityImage.aggregate({
|
||||
where: { organizationId },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
this.prisma.noteAttachment.aggregate({
|
||||
where: { organizationId },
|
||||
_sum: { sizeBytes: true },
|
||||
}),
|
||||
this.prisma.financialAttachment.findMany({
|
||||
where: { organizationId },
|
||||
select: { storageKey: true },
|
||||
}),
|
||||
this.prisma.lead.findMany({
|
||||
where: { organizationId, logoStorageKey: { not: null } },
|
||||
select: { logoStorageKey: true },
|
||||
}),
|
||||
this.prisma.leadForm.findMany({
|
||||
where: { organizationId, coverStorageKey: { not: null } },
|
||||
select: { coverStorageKey: true },
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: { avatarStorageKey: { startsWith: `${organizationId}/` } },
|
||||
select: { avatarStorageKey: true },
|
||||
}),
|
||||
]);
|
||||
const keys = [
|
||||
...treasury.map((row) => row.storageKey),
|
||||
...leads.map((row) => row.logoStorageKey).filter((key): key is string => Boolean(key)),
|
||||
...forms.map((row) => row.coverStorageKey).filter((key): key is string => Boolean(key)),
|
||||
...users.map((row) => row.avatarStorageKey).filter((key): key is string => Boolean(key)),
|
||||
];
|
||||
let disk = 0;
|
||||
for (const key of keys) disk += await this.files.sizeOf(key);
|
||||
const bytes = (imageSum._sum.sizeBytes ?? 0) + (noteSum._sum.sizeBytes ?? 0) + disk;
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { fileBytes: BigInt(bytes), fileBytesSyncedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private empty(): StorageUsageDto {
|
||||
return { usedBytes: 0, quotaBytes: null, percent: null, level: 'unlimited' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
ASSIGN_SECTION_PERMISSION,
|
||||
type AssignSection,
|
||||
} from '@mento/shared';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { elevated } from './record-scope';
|
||||
|
||||
const ASSIGN_NOUN: Record<AssignSection, string> = {
|
||||
customers: 'مشتری',
|
||||
leads: 'لید',
|
||||
sales: 'معامله',
|
||||
activities: 'فعالیت',
|
||||
routines: 'کار روتین',
|
||||
projects: 'کارت پروژه',
|
||||
tickets: 'تیکت',
|
||||
};
|
||||
|
||||
type AssignActor = { userId: string; role: string; organizationId: string };
|
||||
|
||||
export function assignOthersDeniedMessage(section: AssignSection): string {
|
||||
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط میتوانید برای خودتان ثبت کنید.`;
|
||||
}
|
||||
|
||||
export async function actorCanAssignToOthers(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
): Promise<boolean> {
|
||||
if (elevated(actor.role)) return true;
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: actor.userId,
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = membership?.orgRole?.permissions;
|
||||
const key = ASSIGN_SECTION_PERMISSION[section];
|
||||
return Array.isArray(permissions) && permissions.includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
|
||||
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
|
||||
*/
|
||||
export async function assertAssigneeSet(
|
||||
prisma: PrismaService,
|
||||
actor: AssignActor,
|
||||
section: AssignSection,
|
||||
nextIds: Array<string | null | undefined>,
|
||||
previousIds: Array<string | null | undefined> = [],
|
||||
): Promise<void> {
|
||||
if (await actorCanAssignToOthers(prisma, actor, section)) return;
|
||||
const prev = new Set(
|
||||
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
|
||||
);
|
||||
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
|
||||
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
|
||||
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
DataAccessRule,
|
||||
DataAccessSection,
|
||||
DataVisibility,
|
||||
DataVisibilityScopes,
|
||||
SectionDataAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_LEGACY_SCOPE,
|
||||
DATA_ACCESS_SECTIONS,
|
||||
migrateVisibilityToSectionAccess,
|
||||
parseDataAccessPolicy,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
import { canViewAllRecords } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { Actor } from './record-scope';
|
||||
|
||||
function elevated(role: string | null | undefined): boolean {
|
||||
return canViewAllRecords(role);
|
||||
}
|
||||
|
||||
export type SectionAccessContext = {
|
||||
section: SectionDataAccess;
|
||||
source: 'role' | 'override' | 'default';
|
||||
};
|
||||
|
||||
function ownerIdList(
|
||||
rule: DataAccessRule,
|
||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
||||
): string[] {
|
||||
const ownerIds = new Set<string>();
|
||||
if (!rule.owners?.length) return [];
|
||||
if (rule.owners.includes('self')) ownerIds.add(ctx.userId);
|
||||
if (rule.owners.includes('team')) {
|
||||
const peers = ctx.teamPeerIds?.length ? ctx.teamPeerIds : [ctx.userId];
|
||||
for (const id of peers) ownerIds.add(id);
|
||||
}
|
||||
return [...ownerIds];
|
||||
}
|
||||
|
||||
/** Resolve یک بخش: override کاربر بر نقش؛ وگرنه مهاجرت از visibility قدیمی. */
|
||||
export function resolveSectionAccess(
|
||||
sectionKey: DataAccessSection,
|
||||
input: {
|
||||
role: string;
|
||||
rolePolicy?: unknown;
|
||||
override?: unknown;
|
||||
legacyVisibility?: DataVisibility | string | null;
|
||||
legacyScopes?: DataVisibilityScopes | null;
|
||||
},
|
||||
): SectionAccessContext {
|
||||
if (elevated(input.role)) {
|
||||
return { section: { mode: 'all', rules: [] }, source: 'default' };
|
||||
}
|
||||
const override = parseDataAccessPolicy(input.override);
|
||||
if (override[sectionKey]) {
|
||||
return { section: override[sectionKey]!, source: 'override' };
|
||||
}
|
||||
const rolePolicy = parseDataAccessPolicy(input.rolePolicy);
|
||||
if (rolePolicy[sectionKey]) {
|
||||
return { section: rolePolicy[sectionKey]!, source: 'role' };
|
||||
}
|
||||
// فرم محدودهٔ نقش، بخشِ ننوشته را «بدون محدودیت» نشان میدهد.
|
||||
// اگر نقش حتی یک بخش ذخیره کرده باشد، بقیه نباید به دید قدیمی own/team برگردند.
|
||||
if (Object.keys(rolePolicy).length > 0) {
|
||||
return { section: { mode: 'all', rules: [] }, source: 'role' };
|
||||
}
|
||||
if (input.legacyVisibility != null || input.legacyScopes) {
|
||||
return {
|
||||
section: migrateVisibilityToSectionAccess(
|
||||
input.legacyVisibility,
|
||||
input.legacyScopes,
|
||||
DATA_ACCESS_LEGACY_SCOPE[sectionKey],
|
||||
),
|
||||
source: 'default',
|
||||
};
|
||||
}
|
||||
return { section: { mode: 'all', rules: [] }, source: 'default' };
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function resolveCustomersAccess(input: {
|
||||
role: string;
|
||||
rolePolicy?: unknown;
|
||||
override?: unknown;
|
||||
legacyVisibility?: DataVisibility | string | null;
|
||||
legacyScopes?: DataVisibilityScopes | null;
|
||||
}): SectionAccessContext {
|
||||
return resolveSectionAccess('customers', input);
|
||||
}
|
||||
|
||||
export function resolveAllSectionAccess(input: {
|
||||
role: string;
|
||||
rolePolicy?: unknown;
|
||||
override?: unknown;
|
||||
legacyVisibility?: DataVisibility | string | null;
|
||||
legacyScopes?: DataVisibilityScopes | null;
|
||||
}): DataAccessPolicy {
|
||||
const out: DataAccessPolicy = {};
|
||||
for (const key of DATA_ACCESS_SECTIONS) {
|
||||
out[key] = resolveSectionAccess(key, input).section;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildContactRuleWhere(
|
||||
rule: DataAccessRule,
|
||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
||||
): Prisma.PersonWhereInput | null {
|
||||
const and: Prisma.PersonWhereInput[] = [];
|
||||
const ids = ownerIdList(rule, ctx);
|
||||
if (rule.owners?.length) {
|
||||
if (!ids.length) return null;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (rule.moods?.length) {
|
||||
and.push({ mood: { in: rule.moods } });
|
||||
}
|
||||
if (!and.length) return null;
|
||||
return and.length === 1 ? and[0]! : { AND: and };
|
||||
}
|
||||
|
||||
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
|
||||
if (!parts.length) return { id: '__no_access__' } as T & { id: string };
|
||||
return parts.length === 1 ? parts[0]! : { OR: parts };
|
||||
}
|
||||
|
||||
export function customersPersonWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
||||
): Prisma.PersonWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.PersonWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const w = buildContactRuleWhere(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
export function customersCompanyWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
||||
): Prisma.CompanyWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.CompanyWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.CompanyWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule.moods?.length &&
|
||||
!rule.owners?.length &&
|
||||
!rule.groupIds?.length &&
|
||||
!rule.sources?.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
export function leadsDeepWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'leadsAccess' | 'teamPeerIds'>,
|
||||
): Prisma.LeadWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.leadsAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.LeadWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.LeadWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
OR: rule.sources.map((s) => ({
|
||||
source: { equals: s, mode: 'insensitive' as const },
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
export function dealsDeepWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'salesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.DealWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.salesAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.DealWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.DealWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (rule.pipelineIds?.length) {
|
||||
and.push({ pipelineId: { in: rule.pipelineIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
export function activitiesDeepWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'activitiesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.activitiesAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.ActivityWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.ActivityWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ typeDefId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
export function notesDeepWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'notesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.NoteWhereInput {
|
||||
const personalGate: Prisma.NoteWhereInput = {
|
||||
OR: [
|
||||
{ visibility: { not: 'personal' } },
|
||||
{ visibility: 'personal', authorId: actor.userId },
|
||||
],
|
||||
};
|
||||
if (elevated(actor.role)) return personalGate;
|
||||
const section = actor.notesAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return personalGate;
|
||||
const parts: Prisma.NoteWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.NoteWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ authorId: { in: ids } },
|
||||
{
|
||||
deal: {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ person: { ownerId: { in: ids } } },
|
||||
{ company: { ownerId: { in: ids } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({ noteType: { in: rule.sources } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return {
|
||||
AND: [personalGate, orParts(parts)],
|
||||
};
|
||||
}
|
||||
|
||||
export function ticketsDeepWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'ticketsAccess' | 'teamPeerIds'>,
|
||||
): Prisma.TicketWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.ticketsAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.TicketWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.TicketWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
});
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
status: {
|
||||
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
export function projectsDeepWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'projectsAccess' | 'teamPeerIds'>,
|
||||
): Prisma.ProjectCardWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.projectsAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.ProjectCardWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.ProjectCardWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
export function routinesDeepWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.RoutineTaskWhereInput {
|
||||
if (elevated(actor.role)) return {};
|
||||
const section = actor.routinesAccess ?? { mode: 'all' as const, rules: [] };
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.RoutineTaskWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.RoutineTaskWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ categoryId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
/** اتحاد شناسهٔ owners در قوانین؛ null = بدون محدودیت مالکی (یا mode=all) */
|
||||
export function sectionOwnerIdUnion(
|
||||
section: SectionDataAccess | null | undefined,
|
||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
||||
): string[] | null {
|
||||
if (!sectionHasDataAccessLimits(section)) return null;
|
||||
const ids = new Set<string>();
|
||||
let sawOwnerRule = false;
|
||||
let sawNonOwnerRule = false;
|
||||
for (const rule of section?.rules ?? []) {
|
||||
if (rule.owners?.length) {
|
||||
sawOwnerRule = true;
|
||||
for (const id of ownerIdList(rule, ctx)) ids.add(id);
|
||||
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
|
||||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
|
||||
rule.typeIds?.length) {
|
||||
sawNonOwnerRule = true;
|
||||
}
|
||||
}
|
||||
if (sawNonOwnerRule && !sawOwnerRule) return null;
|
||||
if (!sawOwnerRule) return null;
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function sectionNeedsTeamPeers(section: SectionDataAccess | null | undefined): boolean {
|
||||
if (!sectionHasDataAccessLimits(section)) return false;
|
||||
return (section?.rules ?? []).some((r) => r.owners?.includes('team'));
|
||||
}
|
||||
|
||||
export function sanitizeDataAccessPolicyInput(raw: unknown): DataAccessPolicy {
|
||||
return parseDataAccessPolicy(raw);
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
|
||||
import type {
|
||||
DataScope,
|
||||
DataVisibility,
|
||||
DataVisibilityScopes,
|
||||
SectionDataAccess,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
activitiesDeepWhere,
|
||||
dealsDeepWhere,
|
||||
notesDeepWhere,
|
||||
projectsDeepWhere,
|
||||
routinesDeepWhere,
|
||||
ticketsDeepWhere,
|
||||
} from './data-access';
|
||||
|
||||
export type ScopeVisibility = {
|
||||
dataVisibility: DataVisibility;
|
||||
@@ -20,6 +34,17 @@ export type Actor = {
|
||||
visibleUserIds?: string[] | null;
|
||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
||||
/** عمق دادهٔ بخشها (resolve شده از نقش + override) */
|
||||
customersAccess?: SectionDataAccess;
|
||||
leadsAccess?: SectionDataAccess;
|
||||
salesAccess?: SectionDataAccess;
|
||||
activitiesAccess?: SectionDataAccess;
|
||||
notesAccess?: SectionDataAccess;
|
||||
routinesAccess?: SectionDataAccess;
|
||||
projectsAccess?: SectionDataAccess;
|
||||
ticketsAccess?: SectionDataAccess;
|
||||
/** همتیمیها برای قوانین owners:team (شامل خود کاربر) */
|
||||
teamPeerIds?: string[] | null;
|
||||
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
||||
priorLens?: 'off' | 'current' | 'archive';
|
||||
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیتهای ساختهشده قبل از آن میتوانند قبلی باشند */
|
||||
@@ -93,10 +118,13 @@ export function ownerWhere(
|
||||
return { ownerId: { in: ids } };
|
||||
}
|
||||
|
||||
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
|
||||
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
|
||||
export function dealAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.DealWhereInput {
|
||||
if (actor.salesAccess !== undefined) {
|
||||
return dealsDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'deals');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -107,10 +135,13 @@ export function dealAccessWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
|
||||
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
|
||||
export function projectCardAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
|
||||
): Prisma.ProjectCardWhereInput {
|
||||
if (actor.projectsAccess !== undefined) {
|
||||
return projectsDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'projects');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -135,10 +166,13 @@ export function assigneeWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** دسترسی به فعالیت: مسئولان یا شرکتکنندگان (فیلد شرکتکنندگان) در محدوده دید */
|
||||
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
|
||||
export function activityAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (actor.activitiesAccess !== undefined) {
|
||||
return activitiesDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -150,10 +184,13 @@ export function activityAccessWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** یادداشت: نویسنده یا پروندههای تحت مالکیت در محدوده دید + محرمانگی شخصی */
|
||||
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
|
||||
export function noteAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.NoteWhereInput {
|
||||
if (actor.notesAccess !== undefined) {
|
||||
return notesDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'notes');
|
||||
const personalGate: Prisma.NoteWhereInput = {
|
||||
OR: [
|
||||
@@ -188,17 +225,22 @@ export function noteAccessWhere(
|
||||
export function resolveOwnerId(
|
||||
actor: Pick<Actor, 'userId' | 'role'>,
|
||||
requested?: string | null,
|
||||
allowOthers?: boolean,
|
||||
): string {
|
||||
if (elevated(actor.role) && requested?.trim()) {
|
||||
const allowed = allowOthers ?? elevated(actor.role);
|
||||
if (allowed && requested?.trim()) {
|
||||
return requested.trim();
|
||||
}
|
||||
return actor.userId;
|
||||
}
|
||||
|
||||
/** تیکت: مسئول یا ثبتکننده در محدوده دید */
|
||||
/** تیکت: عمق داده یا مدل قدیمی */
|
||||
export function ticketAccessWhere(
|
||||
actor: ScopedActor,
|
||||
actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
|
||||
): Prisma.TicketWhereInput {
|
||||
if (actor.ticketsAccess !== undefined) {
|
||||
return ticketsDeepWhere(actor as Actor);
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, 'tickets');
|
||||
if (!ids) return {};
|
||||
return {
|
||||
@@ -206,6 +248,14 @@ export function ticketAccessWhere(
|
||||
};
|
||||
}
|
||||
|
||||
/** کار روتین: عمق داده (وقتی JWT ست کرده) */
|
||||
export function routineTaskAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
|
||||
): Prisma.RoutineTaskWhereInput {
|
||||
if (actor.routinesAccess === undefined) return {};
|
||||
return routinesDeepWhere(actor);
|
||||
}
|
||||
|
||||
export function canReassignOwner(role: string | null | undefined): boolean {
|
||||
return elevated(role);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ export function buildSearchWhere<W extends object>(
|
||||
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
||||
}
|
||||
|
||||
/** پارامتر چندتایی کوئری: «a,b,c» → ['a','b','c'] */
|
||||
export function splitCsv(raw: string | null | undefined, max = 50): string[] {
|
||||
if (!raw) return [];
|
||||
return [
|
||||
...new Set(
|
||||
String(raw)
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, max);
|
||||
}
|
||||
|
||||
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
||||
export function ci(term: string) {
|
||||
return { contains: term, mode: 'insensitive' as const };
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { CompaniesService } from './companies.service';
|
||||
@@ -31,7 +32,8 @@ const excelUpload = FileInterceptor('file', {
|
||||
});
|
||||
|
||||
@Controller('companies')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('customers')
|
||||
export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
@@ -45,9 +47,15 @@ export class CompaniesController {
|
||||
query.q,
|
||||
query.page ?? 1,
|
||||
query.limit ?? 20,
|
||||
{ groupId: query.groupId },
|
||||
);
|
||||
}
|
||||
|
||||
@Get('group-stats')
|
||||
groupStats(@CurrentUser() user: Actor) {
|
||||
return this.companiesService.groupStats(user);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateCompanyRequest,
|
||||
CustomerGroupDto,
|
||||
PaginatedResponse,
|
||||
UpdateCompanyRequest,
|
||||
} from '@mento/shared';
|
||||
@@ -16,17 +17,21 @@ import {
|
||||
asCustomFields,
|
||||
toJsonCustomFields,
|
||||
} from '../common/custom-fields';
|
||||
import { customersCompanyWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
elevated,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
CustomerGroupsService,
|
||||
groupSummarySelect,
|
||||
} from '../customer-groups/customer-groups.service';
|
||||
|
||||
function parseDateOrNull(raw?: string | null): Date | null {
|
||||
if (!raw?.trim()) return null;
|
||||
@@ -37,6 +42,10 @@ function parseDateOrNull(raw?: string | null): Date | null {
|
||||
const companyInclude = {
|
||||
_count: { select: { people: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
groupLinks: {
|
||||
select: { group: { select: groupSummarySelect } },
|
||||
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
||||
},
|
||||
} satisfies Prisma.CompanyInclude;
|
||||
|
||||
type CompanyRow = {
|
||||
@@ -67,6 +76,7 @@ type CompanyRow = {
|
||||
updatedAt: Date;
|
||||
_count: { people: number };
|
||||
owner: { id: string; fullName: string } | null;
|
||||
groupLinks: Array<{ group: { id: string; name: string; color: string } }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -74,6 +84,7 @@ export class CompaniesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly game: GamificationService,
|
||||
private readonly groups: CustomerGroupsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -81,6 +92,7 @@ export class CompaniesService {
|
||||
q?: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
opts?: { groupId?: string },
|
||||
): Promise<PaginatedResponse<CompanyDto>> {
|
||||
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
|
||||
{ name: ci(t) },
|
||||
@@ -94,10 +106,15 @@ export class CompaniesService {
|
||||
{ city: ci(t) },
|
||||
{ province: ci(t) },
|
||||
]);
|
||||
const groupIds = splitCsv(opts?.groupId);
|
||||
const and: Prisma.CompanyWhereInput[] = [
|
||||
...(search ? [search] : []),
|
||||
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
||||
];
|
||||
const where: Prisma.CompanyWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(search ? { AND: [search] } : {}),
|
||||
...customersCompanyWhere(actor),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
@@ -119,9 +136,41 @@ export class CompaniesService {
|
||||
};
|
||||
}
|
||||
|
||||
/** آمار گروه برای فیلتر شرکتها — فقط شرکتهای در دسترس کاربر */
|
||||
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
||||
const counts = await this.prisma.companyGroupLink.groupBy({
|
||||
by: ['groupId'],
|
||||
where: {
|
||||
company: {
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
if (!counts.length) return [];
|
||||
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||
const groups = await this.prisma.customerGroup.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
id: { in: counts.map((c) => c.groupId) },
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
color: g.color,
|
||||
position: g.position,
|
||||
fromTahesab: Boolean(g.tahesabGid),
|
||||
personCount: 0,
|
||||
companyCount: countMap.get(g.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async get(actor: Actor, id: string): Promise<CompanyDto> {
|
||||
const row = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
|
||||
include: companyInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('شرکت پیدا نشد');
|
||||
@@ -152,15 +201,27 @@ export class CompaniesService {
|
||||
notes: emptyToNull(dto.notes),
|
||||
registeredAt: parseDateOrNull(dto.registeredAt),
|
||||
tags: normalizeTags(dto.tags),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId: resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||
),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
};
|
||||
await this.assertNoDuplicate(actor.organizationId, data);
|
||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||
|
||||
const row = await this.prisma.company.create({
|
||||
let row = await this.prisma.company.create({
|
||||
data,
|
||||
include: companyInclude,
|
||||
});
|
||||
if (groupIds.length) {
|
||||
await this.groups.addCompanyGroups(row.id, groupIds);
|
||||
row = await this.prisma.company.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: companyInclude,
|
||||
});
|
||||
}
|
||||
if (row.importSource === 'manual') {
|
||||
this.game.emit({
|
||||
organizationId: actor.organizationId,
|
||||
@@ -209,11 +270,21 @@ export class CompaniesService {
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
if (dto.ownerId !== undefined) {
|
||||
const current = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
if (dto.groupIds !== undefined) {
|
||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||
await this.groups.setCompanyGroups(id, groupIds);
|
||||
}
|
||||
const row = await this.prisma.company.update({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -315,6 +386,7 @@ export class CompaniesService {
|
||||
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
|
||||
tags: row.tags ?? [],
|
||||
customFields: asCustomFields(row.customFields),
|
||||
groups: (row.groupLinks ?? []).map((l) => l.group),
|
||||
peopleCount: row._count.people,
|
||||
owner: row.owner,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
|
||||
@@ -2,8 +2,9 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
|
||||
import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customersCompanyWhere } from '../common/data-access';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { resolveOwnerId } from '../common/record-scope';
|
||||
import {
|
||||
collectAddresses,
|
||||
} from '../common/custom-fields';
|
||||
@@ -25,6 +26,10 @@ import { CompaniesService } from './companies.service';
|
||||
import { PeopleService } from './people.service';
|
||||
import { normalizeEmail, normalizePhone } from './normalize';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import {
|
||||
CustomerGroupsService,
|
||||
splitGroupNames,
|
||||
} from '../customer-groups/customer-groups.service';
|
||||
|
||||
type ImportChunkOpts = {
|
||||
sessionId?: string;
|
||||
@@ -39,6 +44,16 @@ function parseMood(raw?: string): PersonMood | undefined {
|
||||
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
|
||||
}
|
||||
|
||||
/** ستونهای گروه اکسل → نام گروهها (برچسبهای وضعیت مشتری گروه حساب نمیشوند) */
|
||||
function groupNamesFrom(...raws: Array<string | undefined>): string[] {
|
||||
const names = raws.flatMap((raw) => splitGroupNames(raw));
|
||||
return [...new Set(names.filter((name) => !parseMood(name)))];
|
||||
}
|
||||
|
||||
function groupNamesText(groups: Array<{ name: string }> | undefined, fallback?: string) {
|
||||
return groups?.length ? groups.map((g) => g.name).join('، ') : (fallback ?? '');
|
||||
}
|
||||
|
||||
function splitDidarName(firstName: string, lastName: string, title?: string) {
|
||||
const first = firstName.trim();
|
||||
const last = lastName.trim();
|
||||
@@ -369,6 +384,7 @@ export class ContactsExcelService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly people: PeopleService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly groups: CustomerGroupsService,
|
||||
) {}
|
||||
|
||||
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
|
||||
@@ -378,6 +394,7 @@ export class ContactsExcelService {
|
||||
CONTACT_COLUMNS,
|
||||
items.map((p) => {
|
||||
const extra = (p.customFields ?? {}) as Record<string, string>;
|
||||
const groupText = groupNamesText(p.groups, extra.group);
|
||||
return {
|
||||
_type: 'Contact',
|
||||
didarId: extra.didarId ?? p.id,
|
||||
@@ -402,7 +419,7 @@ export class ContactsExcelService {
|
||||
nationalId: extra.nationalId ?? '',
|
||||
registeredAt: formatJalaliDate(p.createdAt),
|
||||
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
|
||||
otherPhones: extra.otherPhones ?? '',
|
||||
address: p.addresses[0] ?? extra.address ?? '',
|
||||
website: p.website ?? extra.website ?? '',
|
||||
@@ -410,7 +427,7 @@ export class ContactsExcelService {
|
||||
position2: p.position ?? extra.position ?? '',
|
||||
city2: extra.city ?? '',
|
||||
code2: extra.customerCode ?? '',
|
||||
group2: extra.group ?? '',
|
||||
group2: groupText,
|
||||
address2: p.addresses[1] ?? extra.address ?? '',
|
||||
phone2: extra.phone2 ?? p.phone ?? '',
|
||||
postalCode2: extra.postalCode ?? '',
|
||||
@@ -418,7 +435,7 @@ export class ContactsExcelService {
|
||||
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
|
||||
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
|
||||
instagram: p.instagram ?? extra.instagram ?? '',
|
||||
group: extra.group ?? '',
|
||||
group: groupText,
|
||||
colleagueContact: extra.colleagueContact ?? '',
|
||||
previousCompany: extra.previousCompany ?? '',
|
||||
customerType: extra.customerType ?? '',
|
||||
@@ -456,7 +473,7 @@ export class ContactsExcelService {
|
||||
registeredAt: formatJalaliDate(
|
||||
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
|
||||
),
|
||||
groupName: c.groupName ?? '',
|
||||
groupName: groupNamesText(c.groups, c.groupName ?? ''),
|
||||
otherPhones: c.otherPhones ?? '',
|
||||
address: c.address ?? '',
|
||||
website: c.website ?? '',
|
||||
@@ -551,6 +568,8 @@ export class ContactsExcelService {
|
||||
const value = row[key]?.trim();
|
||||
if (value) customFields[key] = value;
|
||||
}
|
||||
const groupNames = groupNamesFrom(row.group, parseMood(row.mood) ? undefined : row.mood);
|
||||
if (groupNames.length) delete customFields.group;
|
||||
|
||||
const addresses = collectAddresses(
|
||||
row.address,
|
||||
@@ -576,6 +595,7 @@ export class ContactsExcelService {
|
||||
? normalizePersonImportSource(sourceCol)
|
||||
: 'excel';
|
||||
const sourceRef = didarId || customerCode || null;
|
||||
const groupIds = await this.groups.idsForNames(actor.organizationId, groupNames);
|
||||
|
||||
await this.people.create(actor, {
|
||||
firstName: names.firstName,
|
||||
@@ -596,6 +616,7 @@ export class ContactsExcelService {
|
||||
importSource,
|
||||
sourceRef,
|
||||
registeredAt: registeredAt?.toISOString() ?? null,
|
||||
groupIds,
|
||||
});
|
||||
result.created += 1;
|
||||
if (ownerMissing) {
|
||||
@@ -701,8 +722,13 @@ export class ContactsExcelService {
|
||||
};
|
||||
|
||||
try {
|
||||
const groupIds = await this.groups.idsForNames(
|
||||
actor.organizationId,
|
||||
groupNamesFrom(row.groupName),
|
||||
);
|
||||
const existing = await this.findImportedCompany(actor, didarId, name);
|
||||
if (existing) {
|
||||
if (groupIds.length) await this.groups.addCompanyGroups(existing.id, groupIds);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (k === 'customFields') continue;
|
||||
@@ -719,6 +745,7 @@ export class ContactsExcelService {
|
||||
await this.companies.create(actor, {
|
||||
...payload,
|
||||
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
||||
groupIds,
|
||||
});
|
||||
result.created += 1;
|
||||
}
|
||||
@@ -750,7 +777,7 @@ export class ContactsExcelService {
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
sourceRef: didarId,
|
||||
...ownerWhere(actor),
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
select: { id: true, customFields: true },
|
||||
});
|
||||
@@ -761,7 +788,7 @@ export class ContactsExcelService {
|
||||
organizationId: actor.organizationId,
|
||||
name: { equals: name, mode: 'insensitive' },
|
||||
...(didarId ? { sourceRef: null } : {}),
|
||||
...ownerWhere(actor),
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
select: { id: true, customFields: true },
|
||||
});
|
||||
@@ -779,7 +806,7 @@ export class ContactsExcelService {
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
sourceRef: ref,
|
||||
...ownerWhere(actor),
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -789,7 +816,7 @@ export class ContactsExcelService {
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
name: { equals: name, mode: 'insensitive' },
|
||||
...ownerWhere(actor),
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
select: { id: true, sourceRef: true },
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PeopleController } from './people.controller';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { BehaviorModule } from '../behavior/behavior.module';
|
||||
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -17,6 +18,7 @@ import { BehaviorModule } from '../behavior/behavior.module';
|
||||
MessageAutomationsModule,
|
||||
PublicApiModule,
|
||||
BehaviorModule,
|
||||
CustomerGroupsModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||
import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
|
||||
|
||||
export class BirthdaySmsDto {
|
||||
@IsString()
|
||||
@@ -50,18 +51,20 @@ export class ListQueryDto {
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
|
||||
/** وضعیت مشتری؛ چندتایی با کاما */
|
||||
@IsOptional()
|
||||
@IsIn([...PERSON_MOODS])
|
||||
mood?: (typeof PERSON_MOODS)[number];
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
mood?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['name', 'newest'])
|
||||
sort?: 'name' | 'newest';
|
||||
|
||||
/** فیلتر منبع ورود شخص */
|
||||
/** فیلتر منبع ورود شخص؛ چندتایی با کاما */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
@MaxLength(400)
|
||||
importSource?: string;
|
||||
|
||||
/** استان شرکت مرتبط */
|
||||
@@ -86,6 +89,12 @@ export class ListQueryDto {
|
||||
@IsString()
|
||||
@MaxLength(12)
|
||||
disc?: string;
|
||||
|
||||
/** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروهها) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
class CompanyExtraFieldsDto {
|
||||
@@ -153,6 +162,11 @@ class CompanyExtraFieldsDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
registeredAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
export class CreateCompanyDto extends CompanyExtraFieldsDto {
|
||||
@@ -269,6 +283,14 @@ export class CreatePersonDto {
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PERSON_GENDERS)
|
||||
gender?: (typeof PERSON_GENDERS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showGenderInName?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -370,6 +392,11 @@ export class CreatePersonDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
registeredAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdatePersonDto {
|
||||
@@ -383,6 +410,14 @@ export class UpdatePersonDto {
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PERSON_GENDERS)
|
||||
gender?: (typeof PERSON_GENDERS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showGenderInName?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -478,4 +513,9 @@ export class UpdatePersonDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
export function emptyToNull(value?: string | null): string | null {␍
|
||||
if (value == null) return null;␍
|
||||
const trimmed = value.trim();␍
|
||||
return trimmed.length ? trimmed : null;␍
|
||||
}␍
|
||||
␍
|
||||
export function normalizeEmail(value?: string | null): string | null {␍
|
||||
const v = emptyToNull(value);␍
|
||||
return v ? v.toLowerCase() : null;␍
|
||||
}␍
|
||||
␍
|
||||
/** Keep digits and leading + for Iranian/international numbers. */␍
|
||||
export function normalizePhone(value?: string | null): string | null {␍
|
||||
const v = emptyToNull(value);␍
|
||||
if (!v) return null;␍
|
||||
const cleaned = v.replace(/[^\d+]/g, '');␍
|
||||
return cleaned.length ? cleaned : null;␍
|
||||
}␍
|
||||
␍
|
||||
export function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(value?: string | null): string | null {
|
||||
const v = emptyToNull(value);
|
||||
return v ? v.toLowerCase() : null;
|
||||
}
|
||||
|
||||
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
/** ارقام فارسی و عربی را به لاتین برمیگرداند تا از شماره حذف نشوند. */
|
||||
function latinDigits(value: string): string {
|
||||
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const fa = FA_DIGITS.indexOf(ch);
|
||||
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep digits and leading + for Iranian/international numbers. */
|
||||
export function normalizePhone(value?: string | null): string | null {
|
||||
const v = emptyToNull(value);
|
||||
if (!v) return null;
|
||||
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
@@ -33,8 +34,9 @@ const excelUpload = FileInterceptor('file', {
|
||||
});
|
||||
|
||||
@Controller('people')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('customers')
|
||||
@RequirePermission('customers')
|
||||
export class PeopleController {
|
||||
constructor(
|
||||
private readonly peopleService: PeopleService,
|
||||
@@ -60,6 +62,7 @@ export class PeopleController {
|
||||
city: query.city,
|
||||
hasMobile,
|
||||
disc: query.disc,
|
||||
groupId: query.groupId,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -77,6 +80,7 @@ export class PeopleController {
|
||||
city: query.city,
|
||||
hasMobile,
|
||||
disc: query.disc,
|
||||
groupId: query.groupId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,6 +89,11 @@ export class PeopleController {
|
||||
return this.peopleService.sourceStats(user);
|
||||
}
|
||||
|
||||
@Get('group-stats')
|
||||
groupStats(@CurrentUser() user: Actor) {
|
||||
return this.peopleService.groupStats(user);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreatePersonRequest,
|
||||
CustomerGroupDto,
|
||||
FilteredIdsResponse,
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
@@ -17,7 +18,12 @@ import type {
|
||||
PersonMood,
|
||||
UpdatePersonRequest,
|
||||
} from '@mento/shared';
|
||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||
import {
|
||||
FILTERED_IDS_MAX,
|
||||
PERSON_MOODS,
|
||||
applyPersonGenderTitle,
|
||||
splitPersonHonorific,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
@@ -29,12 +35,12 @@ import {
|
||||
toJsonAddresses,
|
||||
toJsonCustomFields,
|
||||
} from '../common/custom-fields';
|
||||
import { customersPersonWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
@@ -45,12 +51,16 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { selectMonthBirthdays } from './birthday-month';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||
import {
|
||||
normalizePersonImportSource,
|
||||
personImportSourceLabel,
|
||||
} from './person-source';
|
||||
import { BehaviorAccessService } from '../behavior/behavior-access.service';
|
||||
import {
|
||||
CustomerGroupsService,
|
||||
groupSummarySelect,
|
||||
} from '../customer-groups/customer-groups.service';
|
||||
|
||||
const personInclude = {
|
||||
company: { select: { id: true, name: true, province: true, city: true } },
|
||||
@@ -58,12 +68,18 @@ const personInclude = {
|
||||
loyaltyTier: {
|
||||
select: { id: true, name: true, minPoints: true, color: true },
|
||||
},
|
||||
groupLinks: {
|
||||
select: { source: true, group: { select: groupSummarySelect } },
|
||||
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
||||
},
|
||||
} satisfies Prisma.PersonInclude;
|
||||
|
||||
type PersonRow = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
gender: string;
|
||||
showGenderInName: boolean;
|
||||
previousFirstName: string | null;
|
||||
previousLastName: string | null;
|
||||
importSource: string;
|
||||
@@ -102,6 +118,7 @@ type PersonRow = {
|
||||
discLabel: string | null;
|
||||
discReliable: boolean;
|
||||
discAssessedAt: Date | null;
|
||||
groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>;
|
||||
};
|
||||
|
||||
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
|
||||
@@ -134,23 +151,27 @@ export class PeopleService implements OnModuleInit {
|
||||
private readonly sms: SmsGatewayService,
|
||||
private readonly outboundSms: OutboundSmsLogService,
|
||||
private readonly behaviorAccess: BehaviorAccessService,
|
||||
private readonly groups: CustomerGroupsService,
|
||||
) {}
|
||||
|
||||
private personListWhere(
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
opts?: {
|
||||
mood?: PersonMood;
|
||||
mood?: string;
|
||||
importSource?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
hasMobile?: boolean;
|
||||
disc?: string;
|
||||
groupId?: string;
|
||||
},
|
||||
): Prisma.PersonWhereInput {
|
||||
const sourceFilter = opts?.importSource?.trim()
|
||||
? normalizePersonImportSource(opts.importSource)
|
||||
: undefined;
|
||||
const sources = [...new Set(splitCsv(opts?.importSource).map(normalizePersonImportSource))];
|
||||
const moods = splitCsv(opts?.mood).filter((m): m is PersonMood =>
|
||||
(PERSON_MOODS as readonly string[]).includes(m),
|
||||
);
|
||||
const groupIds = splitCsv(opts?.groupId);
|
||||
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
||||
{ firstName: ci(t) },
|
||||
{ lastName: ci(t) },
|
||||
@@ -190,11 +211,12 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
const disc = discFilter(opts?.disc);
|
||||
if (disc) and.push(disc);
|
||||
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(opts?.mood ? { mood: opts.mood } : {}),
|
||||
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
||||
...customersPersonWhere(actor),
|
||||
...(moods.length ? { mood: { in: moods } } : {}),
|
||||
...(sources.length ? { importSource: { in: sources } } : {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
};
|
||||
}
|
||||
@@ -205,13 +227,14 @@ export class PeopleService implements OnModuleInit {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
opts?: {
|
||||
mood?: PersonMood;
|
||||
mood?: string;
|
||||
sort?: 'name' | 'newest';
|
||||
importSource?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
hasMobile?: boolean;
|
||||
disc?: string;
|
||||
groupId?: string;
|
||||
},
|
||||
): Promise<PaginatedResponse<PersonDto>> {
|
||||
const seeDisc = await this.behaviorAccess.canView(actor);
|
||||
@@ -243,12 +266,13 @@ export class PeopleService implements OnModuleInit {
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
opts?: {
|
||||
mood?: PersonMood;
|
||||
mood?: string;
|
||||
importSource?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
hasMobile?: boolean;
|
||||
disc?: string;
|
||||
groupId?: string;
|
||||
},
|
||||
max = FILTERED_IDS_MAX,
|
||||
): Promise<FilteredIdsResponse> {
|
||||
@@ -274,7 +298,7 @@ export class PeopleService implements OnModuleInit {
|
||||
by: ['importSource'],
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
@@ -287,6 +311,38 @@ export class PeopleService implements OnModuleInit {
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/** آمار گروه برای فیلتر اشخاص — فقط افراد در دسترس کاربر */
|
||||
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
|
||||
const counts = await this.prisma.personGroupLink.groupBy({
|
||||
by: ['groupId'],
|
||||
where: {
|
||||
person: {
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
if (!counts.length) return [];
|
||||
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||
const groups = await this.prisma.customerGroup.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
id: { in: counts.map((c) => c.groupId) },
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
color: g.color,
|
||||
position: g.position,
|
||||
fromTahesab: Boolean(g.tahesabGid),
|
||||
personCount: countMap.get(g.id) ?? 0,
|
||||
companyCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
void this.backfillStoredBirthDates();
|
||||
}
|
||||
@@ -354,7 +410,7 @@ export class PeopleService implements OnModuleInit {
|
||||
|
||||
async get(actor: Actor, id: string): Promise<PersonDto> {
|
||||
const row = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
|
||||
include: personInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('شخص پیدا نشد');
|
||||
@@ -368,10 +424,18 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: dto.firstName ?? '',
|
||||
lastName: dto.lastName ?? '',
|
||||
gender: dto.gender,
|
||||
showGenderInName: dto.showGenderInName,
|
||||
});
|
||||
const data: Prisma.PersonUncheckedCreateInput = {
|
||||
organizationId: actor.organizationId,
|
||||
firstName: (dto.firstName ?? '').trim(),
|
||||
lastName: (dto.lastName ?? '').trim(),
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
previousFirstName: emptyToNull(dto.previousFirstName),
|
||||
previousLastName: emptyToNull(dto.previousLastName),
|
||||
importSource: normalizePersonImportSource(dto.importSource),
|
||||
@@ -389,18 +453,24 @@ export class PeopleService implements OnModuleInit {
|
||||
mood: dto.mood ?? 'newcomer',
|
||||
tags: normalizeTags(dto.tags),
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId: resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
|
||||
),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
...(registeredAt && !Number.isNaN(registeredAt.getTime())
|
||||
? { createdAt: registeredAt }
|
||||
: {}),
|
||||
};
|
||||
await this.assertNoDuplicate(actor.organizationId, data);
|
||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||
|
||||
const row = await this.prisma.person.create({
|
||||
data,
|
||||
include: personInclude,
|
||||
});
|
||||
if (groupIds.length) await this.groups.addPersonGroups(row.id, groupIds);
|
||||
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
||||
const createdCtx = {
|
||||
organizationId: actor.organizationId,
|
||||
@@ -446,8 +516,32 @@ export class PeopleService implements OnModuleInit {
|
||||
await this.ensureExists(actor, id);
|
||||
|
||||
const data: Prisma.PersonUncheckedUpdateInput = {};
|
||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||
const touchesName =
|
||||
dto.firstName !== undefined ||
|
||||
dto.lastName !== undefined ||
|
||||
dto.gender !== undefined ||
|
||||
dto.showGenderInName !== undefined;
|
||||
if (touchesName) {
|
||||
const existing = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { firstName: true, lastName: true, gender: true, showGenderInName: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('شخص پیدا نشد');
|
||||
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
|
||||
const storedGender =
|
||||
existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none';
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName,
|
||||
lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName,
|
||||
gender: dto.gender !== undefined ? dto.gender : storedGender,
|
||||
showGenderInName:
|
||||
dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName,
|
||||
});
|
||||
data.firstName = named.firstName;
|
||||
data.lastName = named.lastName;
|
||||
data.gender = named.gender;
|
||||
data.showGenderInName = named.showGenderInName;
|
||||
}
|
||||
if ('previousFirstName' in dto) {
|
||||
data.previousFirstName = emptyToNull(dto.previousFirstName);
|
||||
}
|
||||
@@ -484,11 +578,21 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
data.companyId = nextCompanyId;
|
||||
}
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
if (dto.ownerId !== undefined) {
|
||||
const current = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
|
||||
await this.assertNoDuplicate(actor.organizationId, data, id);
|
||||
if (dto.groupIds !== undefined) {
|
||||
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
|
||||
await this.groups.setPersonGroups(id, groupIds);
|
||||
}
|
||||
const row = await this.prisma.person.update({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -516,7 +620,7 @@ export class PeopleService implements OnModuleInit {
|
||||
where: {
|
||||
id: { in: unique },
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
});
|
||||
return { deleted: res.count };
|
||||
@@ -524,7 +628,7 @@ export class PeopleService implements OnModuleInit {
|
||||
|
||||
private async ensureExists(actor: Actor, id: string) {
|
||||
const found = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('شخص پیدا نشد');
|
||||
@@ -754,6 +858,8 @@ export class PeopleService implements OnModuleInit {
|
||||
previousImportSource: row.previousImportSource,
|
||||
previousSourceRef: row.previousSourceRef,
|
||||
fullName: `${row.firstName} ${row.lastName}`.trim(),
|
||||
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
||||
showGenderInName: row.showGenderInName,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
mobile: row.mobile,
|
||||
@@ -776,6 +882,10 @@ export class PeopleService implements OnModuleInit {
|
||||
mood: row.mood,
|
||||
tags: row.tags ?? [],
|
||||
customFields: asCustomFields(row.customFields),
|
||||
groups: (row.groupLinks ?? []).map((l) => ({
|
||||
...l.group,
|
||||
source: l.source === 'tahesab' ? ('tahesab' as const) : ('manual' as const),
|
||||
})),
|
||||
loyaltyPoints: row.loyaltyPoints ?? 0,
|
||||
loyaltyTier: row.loyaltyTier,
|
||||
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import {
|
||||
ConversationFileBytesDto,
|
||||
CreateConversationDmDto,
|
||||
CreateConversationGroupDto,
|
||||
CreateConversationChannelDto,
|
||||
@@ -19,12 +20,34 @@ import {
|
||||
ToggleMessageReactionDto,
|
||||
} from './dto';
|
||||
import { ConversationsService } from './conversations.service';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
|
||||
@Controller('conversations')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('conversations')
|
||||
export class ConversationsController {
|
||||
constructor(private readonly conversations: ConversationsService) {}
|
||||
constructor(
|
||||
private readonly conversations: ConversationsService,
|
||||
private readonly storage: StorageQuotaService,
|
||||
) {}
|
||||
|
||||
@Post('files/reserve')
|
||||
reserveFile(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ConversationFileBytesDto,
|
||||
) {
|
||||
return this.storage.assertCanAdd(user.organizationId, dto.bytes).then(() =>
|
||||
this.storage.usageForOrganization(user.organizationId),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('files/commit')
|
||||
commitFile(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ConversationFileBytesDto,
|
||||
) {
|
||||
return this.storage.addChat(user.organizationId, dto.bytes);
|
||||
}
|
||||
|
||||
@Get('access')
|
||||
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
||||
|
||||
@@ -656,7 +656,7 @@ export class ConversationsService {
|
||||
});
|
||||
if (!row) {
|
||||
throw new BadRequestException(
|
||||
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیطها → تنظیمات گفتگو پیکربندی کنید.',
|
||||
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
@@ -32,6 +36,14 @@ export class UpdateConversationSettingsDto {
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export class ConversationFileBytesDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100 * 1024 * 1024)
|
||||
bytes!: number;
|
||||
}
|
||||
|
||||
export class CreateConversationDmDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -15,6 +15,7 @@ export enum CustomFieldEntityDto {
|
||||
person = 'person',
|
||||
company = 'company',
|
||||
deal = 'deal',
|
||||
note = 'note',
|
||||
}
|
||||
|
||||
export enum CustomFieldTypeDto {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
|
||||
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 { CustomerGroupsService } from './customer-groups.service';
|
||||
|
||||
class CreateCustomerGroupDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||
color?: CustomerGroupColor;
|
||||
}
|
||||
|
||||
class UpdateCustomerGroupDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||
color?: CustomerGroupColor;
|
||||
|
||||
/** null گروه را اصلی میکند */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
@Controller('customer-groups')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class CustomerGroupsController {
|
||||
constructor(private readonly groups: CustomerGroupsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: Actor) {
|
||||
return this.groups.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateCustomerGroupDto) {
|
||||
return this.groups.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCustomerGroupDto,
|
||||
) {
|
||||
return this.groups.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.groups.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CustomerGroupsController } from './customer-groups.controller';
|
||||
import { CustomerGroupsService } from './customer-groups.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CustomerGroupsController],
|
||||
providers: [CustomerGroupsService],
|
||||
exports: [CustomerGroupsService],
|
||||
})
|
||||
export class CustomerGroupsModule {}
|
||||
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
|
||||
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const NAME_MAX = 80;
|
||||
|
||||
/** نام گروه برای مقایسه: فاصلههای اضافه و ی/ک عربی یکسان میشوند */
|
||||
function normalizeGroupName(raw: string): string {
|
||||
return raw
|
||||
.replace(/\u064A/g, '\u06CC')
|
||||
.replace(/\u0643/g, '\u06A9')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, NAME_MAX);
|
||||
}
|
||||
|
||||
/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */
|
||||
export function splitGroupNames(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/\s*[،,/]\s*/)
|
||||
.map(normalizeGroupName)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const groupSummarySelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
} satisfies Prisma.CustomerGroupSelect;
|
||||
|
||||
@Injectable()
|
||||
export class CustomerGroupsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(organizationId: string): Promise<CustomerGroupDto[]> {
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { people: true, companies: true } },
|
||||
parent: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
fromTahesab: Boolean(r.tahesabGid),
|
||||
personCount: r._count.people,
|
||||
companyCount: r._count.companies,
|
||||
parentId: r.parentId,
|
||||
parentName: r.parent?.name ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: { name: string; color?: CustomerGroupColor },
|
||||
): Promise<CustomerGroupDto> {
|
||||
const name = this.cleanName(dto.name);
|
||||
const max = await this.prisma.customerGroup.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
await this.prisma.customerGroup.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name,
|
||||
color: this.cleanColor(dto.color),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
return (await this.list(organizationId)).find((g) => g.name === name)!;
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
|
||||
): Promise<CustomerGroupDto> {
|
||||
await this.require(organizationId, id);
|
||||
const parentId =
|
||||
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
|
||||
if (parentId !== undefined) {
|
||||
await this.assertParent(organizationId, id, parentId);
|
||||
}
|
||||
try {
|
||||
await this.prisma.customerGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.mapError(e);
|
||||
}
|
||||
return (await this.list(organizationId)).find((g) => g.id === id)!;
|
||||
}
|
||||
|
||||
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
|
||||
private async assertParent(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
) {
|
||||
if (parentId == null) return;
|
||||
if (parentId === id) {
|
||||
throw new BadRequestException('گروه نمیتواند زیرمجموعهٔ خودش باشد.');
|
||||
}
|
||||
const parent = await this.prisma.customerGroup.findFirst({
|
||||
where: { id: parentId, organizationId },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
|
||||
if (parent.parentId) {
|
||||
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
|
||||
}
|
||||
const childCount = await this.prisma.customerGroup.count({
|
||||
where: { organizationId, parentId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمیتواند فرعی شود.');
|
||||
}
|
||||
}
|
||||
|
||||
/** مشتریها حذف نمیشوند؛ فقط این گروه از رویشان برداشته میشود */
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.require(organizationId, id);
|
||||
await this.prisma.customerGroup.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/** شناسههای معتبر همین محیط (بقیه نادیده گرفته میشوند) */
|
||||
async validIds(organizationId: string, ids: string[] | undefined): Promise<string[]> {
|
||||
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
|
||||
if (!unique.length) return [];
|
||||
const rows = await this.prisma.customerGroup.findMany({
|
||||
where: { organizationId, id: { in: unique } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (rows.length !== unique.length) {
|
||||
throw new BadRequestException('یکی از گروههای انتخابشده معتبر نیست');
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/** گروهها با نام (برای اکسل)؛ نبود → ساخته میشود */
|
||||
async idsForNames(organizationId: string, names: string[]): Promise<string[]> {
|
||||
const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))];
|
||||
if (!clean.length) return [];
|
||||
const ids: string[] = [];
|
||||
for (const name of clean) {
|
||||
ids.push((await this.findOrCreateByName(organizationId, name)).id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async findOrCreateByName(organizationId: string, name: string) {
|
||||
const existing = await this.prisma.customerGroup.findFirst({
|
||||
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
|
||||
select: { id: true, tahesabGid: true },
|
||||
});
|
||||
if (existing) return existing;
|
||||
const max = await this.prisma.customerGroup.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
try {
|
||||
return await this.prisma.customerGroup.create({
|
||||
data: { organizationId, name, position: (max._max.position ?? -1) + 1 },
|
||||
select: { id: true, tahesabGid: true },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return this.prisma.customerGroup.findFirstOrThrow({
|
||||
where: { organizationId, name },
|
||||
select: { id: true, tahesabGid: true },
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* گروه متناظر یک گروه تهحساب: اول با GID روی همان اتصال، بعد با نام.
|
||||
* با تغییر نام در تهحساب گروه تکراری ساخته نمیشود.
|
||||
*/
|
||||
async ensureTahesabGroup(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
gid: string | null,
|
||||
rawName: string,
|
||||
): Promise<string | null> {
|
||||
const name = normalizeGroupName(rawName);
|
||||
const cleanGid = gid?.trim() || null;
|
||||
if (!name && !cleanGid) return null;
|
||||
if (cleanGid) {
|
||||
const byGid = await this.prisma.customerGroup.findFirst({
|
||||
where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (byGid) {
|
||||
if (name && byGid.name !== name) {
|
||||
await this.prisma.customerGroup
|
||||
.update({ where: { id: byGid.id }, data: { name } })
|
||||
.catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی میماند
|
||||
}
|
||||
return byGid.id;
|
||||
}
|
||||
}
|
||||
if (!name) return null;
|
||||
const group = await this.findOrCreateByName(organizationId, name);
|
||||
if (cleanGid && !group.tahesabGid) {
|
||||
await this.prisma.customerGroup.update({
|
||||
where: { id: group.id },
|
||||
data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid },
|
||||
});
|
||||
}
|
||||
return group.id;
|
||||
}
|
||||
|
||||
/** گروههای دستی شخص (جایگزین کامل). منبع لینکهایی که میمانند حفظ میشود. */
|
||||
async setPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.personGroupLink.deleteMany({
|
||||
where: { personId, groupId: { notIn: groupIds } },
|
||||
}),
|
||||
this.prisma.personGroupLink.createMany({
|
||||
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async setCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.companyGroupLink.deleteMany({
|
||||
where: { companyId, groupId: { notIn: groupIds } },
|
||||
}),
|
||||
this.prisma.companyGroupLink.createMany({
|
||||
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/** فقط اضافه میکند (ایمپورت اکسل) */
|
||||
async addPersonGroups(personId: string, groupIds: string[]): Promise<void> {
|
||||
if (!groupIds.length) return;
|
||||
await this.prisma.personGroupLink.createMany({
|
||||
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async addCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
|
||||
if (!groupIds.length) return;
|
||||
await this.prisma.companyGroupLink.createMany({
|
||||
data: groupIds.map((groupId) => ({ companyId, groupId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* گروهِ آمده از تهحساب: گروه تهحسابی قبلی برداشته و این یکی گذاشته میشود.
|
||||
* گروههای دستی دست نمیخورند. اگر همین گروه از قبل دستی بوده، دستی میماند.
|
||||
* @returns آیا چیزی عوض شد
|
||||
*/
|
||||
async setPersonTahesabGroup(personId: string, groupId: string | null): Promise<boolean> {
|
||||
const current = await this.prisma.personGroupLink.findMany({
|
||||
where: { personId },
|
||||
select: { groupId: true, source: true },
|
||||
});
|
||||
const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId);
|
||||
const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true;
|
||||
if (!staleTahesab.length && alreadyLinked) return false;
|
||||
await this.prisma.$transaction([
|
||||
...(staleTahesab.length
|
||||
? [
|
||||
this.prisma.personGroupLink.deleteMany({
|
||||
where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(groupId && !alreadyLinked
|
||||
? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })]
|
||||
: []),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** گروهی که موقع «ثبت در تهحساب» فرستاده میشود: اولین گروه (به ترتیب گروهها) */
|
||||
async firstGroupName(personId: string): Promise<string | null> {
|
||||
const link = await this.prisma.personGroupLink.findFirst({
|
||||
where: { personId },
|
||||
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
|
||||
select: { group: { select: { name: true } } },
|
||||
});
|
||||
return link?.group.name ?? null;
|
||||
}
|
||||
|
||||
private async require(organizationId: string, id: string) {
|
||||
const row = await this.prisma.customerGroup.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException('گروه پیدا نشد');
|
||||
}
|
||||
|
||||
private cleanName(raw: string): string {
|
||||
const name = normalizeGroupName(raw ?? '');
|
||||
if (!name) throw new BadRequestException('نام گروه لازم است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private cleanColor(raw?: string): CustomerGroupColor {
|
||||
return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '')
|
||||
? (raw as CustomerGroupColor)
|
||||
: 'teal';
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new ConflictException('گروهی با این نام وجود دارد');
|
||||
}
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { type Actor, elevated } from '../common/record-scope';
|
||||
import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates';
|
||||
import { internalRoutineTaskWhere } from '../routines/internal-routine';
|
||||
import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine';
|
||||
import { GamificationService, parsePeriod, toRuleDto } from './gamification.service';
|
||||
import type {
|
||||
@@ -751,6 +752,7 @@ export class GamificationAdminService {
|
||||
organizationId,
|
||||
status: { not: 'cancelled' },
|
||||
scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) },
|
||||
...internalRoutineTaskWhere,
|
||||
};
|
||||
const [total, done] = await Promise.all([
|
||||
this.prisma.routineTask.count({ where }),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
@@ -176,6 +177,35 @@ export class ImportBulkFromTahesabDto {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class SyncGroupsPageDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(100)
|
||||
@Max(2000)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class ExportPeopleToTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
/** هر درخواست چند نفر؛ هر نفر چند تماس با تهحساب دارد */
|
||||
@IsArray()
|
||||
@ArrayMaxSize(25)
|
||||
@IsString({ each: true })
|
||||
personIds!: string[];
|
||||
}
|
||||
|
||||
export class SearchTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
@@ -403,6 +433,70 @@ export class PushSanadDto {
|
||||
factorCode?: string;
|
||||
}
|
||||
|
||||
export class SyncAsnadReportDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
connectionId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(12)
|
||||
from!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(12)
|
||||
to!: string;
|
||||
|
||||
/** اگر باشد، فقط سندهای همین شخص گرفته میشود */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
/** اگر باشد، فقط مشتریان این گروهها (و زیرگروهها) گرفته میشوند */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(200)
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateTahesabInvoiceSettingsDto {
|
||||
/** null = حذف لوگو */
|
||||
@IsOptional()
|
||||
logoDataUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companySubtitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
headerText?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
footerText?: string;
|
||||
}
|
||||
|
||||
// silence unused import warnings for ValidateNested/Type if unused
|
||||
void ValidateNested;
|
||||
void Type;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
TahesabInvoiceSettingsDto,
|
||||
UpdateTahesabInvoiceSettingsRequest,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
const MAX_LOGO_CHARS = 700_000; // ~500KB data URL
|
||||
|
||||
function toDto(row: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
logoDataUrl: string | null;
|
||||
companyName: string;
|
||||
companySubtitle: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
headerText: string;
|
||||
footerText: string;
|
||||
updatedAt: Date;
|
||||
}): TahesabInvoiceSettingsDto {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
logoDataUrl: row.logoDataUrl,
|
||||
companyName: row.companyName,
|
||||
companySubtitle: row.companySubtitle,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
headerText: row.headerText,
|
||||
footerText: row.footerText,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabInvoiceSettingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOrCreate(organizationId: string): Promise<TahesabInvoiceSettingsDto> {
|
||||
const existing = await this.prisma.tahesabInvoiceSettings.findUnique({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (existing) return toDto(existing);
|
||||
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { name: true },
|
||||
});
|
||||
const created = await this.prisma.tahesabInvoiceSettings.create({
|
||||
data: {
|
||||
organizationId,
|
||||
companyName: org?.name ?? '',
|
||||
},
|
||||
});
|
||||
return toDto(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
dto: UpdateTahesabInvoiceSettingsRequest,
|
||||
): Promise<TahesabInvoiceSettingsDto> {
|
||||
if (dto.logoDataUrl != null && dto.logoDataUrl.length > MAX_LOGO_CHARS) {
|
||||
throw new BadRequestException(
|
||||
'حجم لوگو زیاد است؛ تصویر کوچکتری انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
dto.logoDataUrl != null &&
|
||||
dto.logoDataUrl !== '' &&
|
||||
!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dto.logoDataUrl)
|
||||
) {
|
||||
throw new BadRequestException('لوگو باید تصویر با فرمت data URL باشد.');
|
||||
}
|
||||
|
||||
const data = {
|
||||
...(dto.logoDataUrl !== undefined
|
||||
? { logoDataUrl: dto.logoDataUrl?.trim() || null }
|
||||
: {}),
|
||||
...(dto.companyName !== undefined
|
||||
? { companyName: dto.companyName.trim().slice(0, 200) }
|
||||
: {}),
|
||||
...(dto.companySubtitle !== undefined
|
||||
? { companySubtitle: dto.companySubtitle.trim().slice(0, 200) }
|
||||
: {}),
|
||||
...(dto.address !== undefined
|
||||
? { address: dto.address.trim().slice(0, 500) }
|
||||
: {}),
|
||||
...(dto.phone !== undefined
|
||||
? { phone: dto.phone.trim().slice(0, 80) }
|
||||
: {}),
|
||||
...(dto.headerText !== undefined
|
||||
? { headerText: dto.headerText.trim().slice(0, 1000) }
|
||||
: {}),
|
||||
...(dto.footerText !== undefined
|
||||
? { footerText: dto.footerText.trim().slice(0, 1000) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const row = await this.prisma.tahesabInvoiceSettings.upsert({
|
||||
where: { organizationId },
|
||||
create: {
|
||||
organizationId,
|
||||
companyName: '',
|
||||
...data,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
return toDto(row);
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,20 @@ import {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { applyPersonGenderTitle } from '@mento/shared';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
CreateInTahesabDto,
|
||||
ExportPeopleToTahesabDto,
|
||||
ImportBulkFromTahesabDto,
|
||||
ImportFromTahesabDto,
|
||||
ManualLinkDto,
|
||||
MatchPersonDto,
|
||||
SearchTahesabDto,
|
||||
SyncGroupsPageDto,
|
||||
} from './dto';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||
|
||||
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
||||
@@ -71,6 +75,23 @@ function pickBirthDate(row: Record<string, unknown>): Date | null {
|
||||
return parseBirthDate(String(raw));
|
||||
}
|
||||
|
||||
function firstAddress(raw: unknown): string | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const first = raw.find((a) => typeof a === 'string' && a.trim());
|
||||
return typeof first === 'string' ? first.trim() : undefined;
|
||||
}
|
||||
|
||||
/** گروه مشتری در تهحساب (فیلد نام در پاسخ تهحساب «GoroupName» غلطاملایی دارد) */
|
||||
function pickGroup(row: Record<string, unknown>): { gid: string | null; name: string } | null {
|
||||
const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId;
|
||||
const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup;
|
||||
if (rawGid == null && rawName == null) return null;
|
||||
const gid = rawGid == null ? null : String(rawGid).trim() || null;
|
||||
const name = rawName == null ? '' : String(rawName).trim();
|
||||
if (!gid && !name) return null;
|
||||
return { gid, name };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabPersonLinkService {
|
||||
private readonly logger = new Logger(TahesabPersonLinkService.name);
|
||||
@@ -78,8 +99,37 @@ export class TahesabPersonLinkService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly connections: TahesabConnectionsService,
|
||||
private readonly groups: CustomerGroupsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* گروه تهحساب روی شخص: فقط گروهِ آمده از تهحساب عوض میشود و گروههای دستی میمانند.
|
||||
* @returns آیا گروه شخص عوض شد
|
||||
*/
|
||||
private async applyTahesabGroup(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
personId: string,
|
||||
row: Record<string, unknown> | undefined,
|
||||
): Promise<boolean> {
|
||||
const group = row ? pickGroup(row) : null;
|
||||
if (!group) return false;
|
||||
try {
|
||||
const groupId = await this.groups.ensureTahesabGroup(
|
||||
organizationId,
|
||||
connectionId,
|
||||
group.gid,
|
||||
group.name,
|
||||
);
|
||||
return await this.groups.setPersonTahesabGroup(personId, groupId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`گروه تهحساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async getPerson(organizationId: string, personId: string) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
@@ -305,10 +355,17 @@ export class TahesabPersonLinkService {
|
||||
person.mobile ??
|
||||
person.phone ??
|
||||
'';
|
||||
const extra = (person.customFields ?? {}) as Record<string, unknown>;
|
||||
const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : '';
|
||||
const groupName =
|
||||
dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM';
|
||||
const args = SanadBuilders.newMoshtari({
|
||||
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||
groupName: dto.groupName ?? 'CRM',
|
||||
groupName,
|
||||
tel,
|
||||
address: firstAddress(person.addresses),
|
||||
codeMeli: nationalId || undefined,
|
||||
tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined,
|
||||
jensFelez: dto.jensFelez ?? 0,
|
||||
});
|
||||
const res = await api.doNewMoshtari(args);
|
||||
@@ -517,9 +574,9 @@ export class TahesabPersonLinkService {
|
||||
}
|
||||
const name = pickName(row) || `مشتری تهحساب ${dto.moshtariCode}`;
|
||||
const tel = pickTel(row);
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const firstName = parts[0] || name;
|
||||
const lastName = parts.slice(1).join(' ') || '—';
|
||||
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
||||
const firstName = named.firstName;
|
||||
const lastName = named.lastName;
|
||||
const mobile = tel || null;
|
||||
const sourceRef = String(dto.moshtariCode);
|
||||
|
||||
@@ -535,6 +592,8 @@ export class TahesabPersonLinkService {
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: prevFirst || null,
|
||||
@@ -575,6 +634,8 @@ export class TahesabPersonLinkService {
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: byMobile.firstName || null,
|
||||
@@ -594,6 +655,8 @@ export class TahesabPersonLinkService {
|
||||
organizationId,
|
||||
firstName,
|
||||
lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile,
|
||||
phone: mobile,
|
||||
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
||||
@@ -628,6 +691,7 @@ export class TahesabPersonLinkService {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
@@ -645,6 +709,7 @@ export class TahesabPersonLinkService {
|
||||
|
||||
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
||||
let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1);
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
|
||||
if (dto.listAll) {
|
||||
const { api } = await this.connections.getApi(
|
||||
@@ -656,10 +721,11 @@ export class TahesabPersonLinkService {
|
||||
dto.page ?? 1,
|
||||
dto.pageSize,
|
||||
);
|
||||
const listedCodes = listed.rows
|
||||
.map((row) => pickCode(row))
|
||||
.filter((c): c is number => c != null);
|
||||
codes = [...new Set([...codes, ...listedCodes])];
|
||||
for (const row of listed.rows) {
|
||||
const code = pickCode(row);
|
||||
if (code != null) rowsByCode.set(code, row);
|
||||
}
|
||||
codes = [...new Set([...codes, ...rowsByCode.keys()])];
|
||||
} else if (dto.fromCode != null && dto.toCode != null) {
|
||||
if (dto.toCode < dto.fromCode) {
|
||||
throw new BadRequestException('کد پایان باید بزرگتر یا مساوی کد شروع باشد.');
|
||||
@@ -674,10 +740,11 @@ export class TahesabPersonLinkService {
|
||||
dto.connectionId,
|
||||
);
|
||||
const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
|
||||
const listed = asMoshtariList(raw)
|
||||
.map((row) => pickCode(row))
|
||||
.filter((c): c is number => c != null);
|
||||
codes = [...new Set([...codes, ...listed])];
|
||||
for (const row of asMoshtariList(raw)) {
|
||||
const code = pickCode(row);
|
||||
if (code != null) rowsByCode.set(code, row);
|
||||
}
|
||||
codes = [...new Set([...codes, ...rowsByCode.keys()])];
|
||||
}
|
||||
|
||||
if (!codes.length) {
|
||||
@@ -708,11 +775,13 @@ export class TahesabPersonLinkService {
|
||||
const pending = codes.filter((c) => !linkedByCode.has(c));
|
||||
|
||||
let bulkApi: TahesabApi | undefined;
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
const missingRows = pending.filter((c) => !rowsByCode.has(c));
|
||||
if (pending.length) {
|
||||
bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api;
|
||||
const minCode = Math.min(...pending);
|
||||
const maxCode = Math.max(...pending);
|
||||
}
|
||||
if (bulkApi && missingRows.length) {
|
||||
const minCode = Math.min(...missingRows);
|
||||
const maxCode = Math.max(...missingRows);
|
||||
if (maxCode - minCode <= 2000) {
|
||||
try {
|
||||
const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode);
|
||||
@@ -737,6 +806,12 @@ export class TahesabPersonLinkService {
|
||||
data: { birthDate },
|
||||
});
|
||||
}
|
||||
await this.applyTahesabGroup(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linkedPersonId,
|
||||
rowsByCode.get(moshtariCode),
|
||||
);
|
||||
items.push({
|
||||
moshtariCode,
|
||||
ok: true,
|
||||
@@ -776,6 +851,146 @@ export class TahesabPersonLinkService {
|
||||
return { imported, skipped, failed, items };
|
||||
}
|
||||
|
||||
/** یک صفحه (بازهٔ کد) از تهحساب: گروه مشتریهای لینکشده بهروز میشود */
|
||||
async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) {
|
||||
const { api } = await this.connections.getApi(organizationId, dto.connectionId);
|
||||
const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize);
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
for (const row of listed.rows) {
|
||||
const code = pickCode(row);
|
||||
if (code != null) rowsByCode.set(code, row);
|
||||
}
|
||||
const links = rowsByCode.size
|
||||
? await this.prisma.personAccountingLink.findMany({
|
||||
where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } },
|
||||
select: { moshtariCode: true, personId: true },
|
||||
})
|
||||
: [];
|
||||
let updated = 0;
|
||||
for (const link of links) {
|
||||
const changed = await this.applyTahesabGroup(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
link.personId,
|
||||
rowsByCode.get(link.moshtariCode),
|
||||
);
|
||||
if (changed) updated += 1;
|
||||
}
|
||||
return {
|
||||
page: listed.page,
|
||||
fromCode: listed.fromCode,
|
||||
toCode: listed.toCode,
|
||||
rows: listed.rows.length,
|
||||
linked: links.length,
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
/** اشخاصی که روی این اتصال به تهحساب لینک نیستند */
|
||||
async unlinkedPeople(organizationId: string, connectionId: string) {
|
||||
await this.connections.getOrThrow(organizationId, connectionId);
|
||||
const rows = await this.prisma.person.findMany({
|
||||
where: { organizationId, accountingLinks: { none: { connectionId } } },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return { ids: rows.map((r) => r.id), total: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت گروهی در تهحساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛
|
||||
* تطبیق یکتا → لینک، چند نتیجه → رد میشود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری.
|
||||
*/
|
||||
async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) {
|
||||
await this.connections.getOrThrow(organizationId, dto.connectionId);
|
||||
const ids = [...new Set(dto.personIds)];
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: { organizationId, id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
accountingLinks: {
|
||||
where: { connectionId: dto.connectionId },
|
||||
select: { moshtariCode: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const byId = new Map(people.map((p) => [p.id, p]));
|
||||
|
||||
const items: Array<{
|
||||
personId: string;
|
||||
name: string;
|
||||
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
|
||||
moshtariCode?: number;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const personId of ids) {
|
||||
const person = byId.get(personId);
|
||||
if (!person) {
|
||||
items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' });
|
||||
continue;
|
||||
}
|
||||
const name = `${person.firstName} ${person.lastName}`.trim();
|
||||
const existing = person.accountingLinks[0];
|
||||
if (existing) {
|
||||
items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const matched = await this.match(organizationId, personId, {
|
||||
connectionId: dto.connectionId,
|
||||
autoLinkIfUnique: true,
|
||||
});
|
||||
if (matched.status === 'linked' && matched.link) {
|
||||
items.push({
|
||||
personId,
|
||||
name,
|
||||
status: 'matched',
|
||||
moshtariCode: matched.link.moshtariCode,
|
||||
});
|
||||
} else if (matched.status === 'none') {
|
||||
const link = await this.createInTahesab(organizationId, personId, {
|
||||
connectionId: dto.connectionId,
|
||||
});
|
||||
items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode });
|
||||
} else {
|
||||
items.push({ personId, name, status: 'ambiguous' });
|
||||
}
|
||||
} catch (e) {
|
||||
items.push({
|
||||
personId,
|
||||
name,
|
||||
status: 'error',
|
||||
error: e instanceof Error ? e.message : 'ثبت ناموفق بود',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const count = (s: (typeof items)[number]['status']) =>
|
||||
items.filter((i) => i.status === s).length;
|
||||
const summary = {
|
||||
matched: count('matched'),
|
||||
created: count('created'),
|
||||
ambiguous: count('ambiguous'),
|
||||
skipped: count('skipped'),
|
||||
failed: count('error'),
|
||||
};
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'export',
|
||||
status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success',
|
||||
request: { count: ids.length } as Prisma.InputJsonValue,
|
||||
result: summary as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { ...summary, items };
|
||||
}
|
||||
|
||||
/** مشتریهای قبلاً لینکشده که تاریخ تولد ندارند را از فهرست تهحساب پر میکند. */
|
||||
async fillMissingBirthDates() {
|
||||
const connections = await this.prisma.accountingConnection.findMany({
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/** طبقهبندی و خالصکردن فروش/خرید بعد از مرجوعی، برای گزارش تهحساب. */
|
||||
|
||||
export const SALES_FACTOR_KEYS = [
|
||||
'karsakhte',
|
||||
'etiket_gold',
|
||||
'etiket_jewel',
|
||||
'bullion',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
export type SalesFactorKey = (typeof SALES_FACTOR_KEYS)[number];
|
||||
|
||||
export const SALES_FACTOR_LABELS: Record<SalesFactorKey, string> = {
|
||||
karsakhte: 'کار ساخته',
|
||||
etiket_gold: 'اتیکت طلا',
|
||||
etiket_jewel: 'اتیکت جواهر',
|
||||
bullion: 'شمش و سکه',
|
||||
other: 'سایر',
|
||||
};
|
||||
|
||||
export type DocDirection = 'in' | 'out' | 'purchase_return' | 'sale_return' | 'other';
|
||||
|
||||
export type SalesDoc = {
|
||||
bucketKey: string;
|
||||
factor: SalesFactorKey;
|
||||
direction: DocDirection;
|
||||
weight: number;
|
||||
mali: number;
|
||||
count: number;
|
||||
percent: number | null;
|
||||
tarikh: string;
|
||||
};
|
||||
|
||||
export type PercentStats = {
|
||||
first: number | null;
|
||||
last: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
avg: number | null;
|
||||
};
|
||||
|
||||
export type MovementStats = {
|
||||
grossWeight: number;
|
||||
returnWeight: number;
|
||||
netWeight: number;
|
||||
grossMali: number;
|
||||
returnMali: number;
|
||||
netMali: number;
|
||||
grossCount: number;
|
||||
returnCount: number;
|
||||
netCount: number;
|
||||
percent: PercentStats | null;
|
||||
};
|
||||
|
||||
const EMPTY_MOVE: MovementStats = {
|
||||
grossWeight: 0,
|
||||
returnWeight: 0,
|
||||
netWeight: 0,
|
||||
grossMali: 0,
|
||||
returnMali: 0,
|
||||
netMali: 0,
|
||||
grossCount: 0,
|
||||
returnCount: 0,
|
||||
netCount: 0,
|
||||
percent: null,
|
||||
};
|
||||
|
||||
function norm(raw: string): string {
|
||||
return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function classifyDirection(noRaw: string): DocDirection {
|
||||
const no = norm(noRaw);
|
||||
if (no.includes('ورود کارساخته مرجوع')) return 'sale_return';
|
||||
if (no.includes('خروج کارساخته مرجوع')) return 'purchase_return';
|
||||
if (no.includes('ورود کارساخته')) return 'in';
|
||||
if (no.includes('خروج کارساخته')) return 'out';
|
||||
const isReturn = /مرجوع|برگشت/.test(no);
|
||||
const isBuy = /خرید/.test(no);
|
||||
const isSell = /فروش/.test(no);
|
||||
if (isReturn && isBuy) return 'purchase_return';
|
||||
if (isReturn) return 'sale_return';
|
||||
if (isBuy || (/ورود/.test(no) && !isReturn)) return 'in';
|
||||
if (isSell || (/خروج/.test(no) && !isReturn)) return 'out';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function classifyFactor(noRaw: string, noKar: string | null | undefined): SalesFactorKey {
|
||||
const blob = norm(`${noRaw} ${noKar ?? ''}`);
|
||||
if (blob.includes('کارساخته') || blob.includes('کار ساخته')) return 'karsakhte';
|
||||
if (/شمش|سکه/.test(blob)) return 'bullion';
|
||||
if (/جواهر|ساچمه/.test(blob)) return 'etiket_jewel';
|
||||
if (blob.includes('اتیکت')) return 'etiket_gold';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function combinedPercent(
|
||||
darsad: number | null | undefined,
|
||||
talayee: number | null | undefined,
|
||||
maliat: number | null | undefined,
|
||||
): number | null {
|
||||
const parts = [darsad, talayee, maliat].filter(
|
||||
(n): n is number => n != null && Number.isFinite(n),
|
||||
);
|
||||
if (!parts.length) return null;
|
||||
return parts.reduce((sum, n) => sum + n, 0);
|
||||
}
|
||||
|
||||
export function positiveAmount(vazn: number | null | undefined, tabdil: number | null | undefined): number {
|
||||
if (vazn != null && Number.isFinite(vazn) && vazn !== 0) return Math.abs(vazn);
|
||||
if (tabdil != null && Number.isFinite(tabdil)) return Math.abs(tabdil);
|
||||
return 0;
|
||||
}
|
||||
|
||||
type Piece = {
|
||||
tarikh: string;
|
||||
seq: number;
|
||||
weight: number;
|
||||
mali: number;
|
||||
count: number;
|
||||
percent: number | null;
|
||||
};
|
||||
|
||||
/** مرجوعی از آخرین سندهای همان نام کم میشود تا فروش/خریدِ برگشتی در آمار نماند. */
|
||||
function remainingAfterReturns(gross: Piece[], returns: Piece[]): Piece[] {
|
||||
const docs = gross.map((d) => ({ ...d }));
|
||||
let leftW = returns.reduce((s, d) => s + d.weight, 0);
|
||||
let leftM = returns.reduce((s, d) => s + d.mali, 0);
|
||||
let leftC = returns.reduce((s, d) => s + d.count, 0);
|
||||
const order = [...docs].sort((a, b) => {
|
||||
if (a.tarikh !== b.tarikh) return b.tarikh.localeCompare(a.tarikh);
|
||||
return b.seq - a.seq;
|
||||
});
|
||||
for (const doc of order) {
|
||||
if (leftW <= 0 && leftM <= 0 && leftC <= 0) break;
|
||||
const takeW = Math.min(doc.weight, Math.max(leftW, 0));
|
||||
const takeM = Math.min(doc.mali, Math.max(leftM, 0));
|
||||
const takeC = Math.min(doc.count, Math.max(leftC, 0));
|
||||
doc.weight -= takeW;
|
||||
doc.mali -= takeM;
|
||||
doc.count -= takeC;
|
||||
leftW -= takeW;
|
||||
leftM -= takeM;
|
||||
leftC -= takeC;
|
||||
}
|
||||
return docs.filter((d) => d.weight > 1e-9 || d.mali > 1e-6 || d.count > 1e-9);
|
||||
}
|
||||
|
||||
function percentOf(docs: Piece[]): PercentStats | null {
|
||||
const withPercent = docs.filter((d) => d.percent != null && d.weight > 1e-9);
|
||||
if (!withPercent.length) return null;
|
||||
const ordered = [...withPercent].sort((a, b) => {
|
||||
if (a.tarikh !== b.tarikh) return a.tarikh.localeCompare(b.tarikh);
|
||||
return a.seq - b.seq;
|
||||
});
|
||||
const values = ordered.map((d) => d.percent!);
|
||||
const weight = ordered.reduce((s, d) => s + d.weight, 0);
|
||||
const avg =
|
||||
weight > 0
|
||||
? ordered.reduce((s, d) => s + d.percent! * d.weight, 0) / weight
|
||||
: null;
|
||||
return {
|
||||
first: ordered[0]?.percent ?? null,
|
||||
last: ordered[ordered.length - 1]?.percent ?? null,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
avg,
|
||||
};
|
||||
}
|
||||
|
||||
export function movementStats(lines: SalesDoc[], side: 'in' | 'out'): MovementStats {
|
||||
const returnDir: DocDirection = side === 'in' ? 'purchase_return' : 'sale_return';
|
||||
const groups = new Map<string, { gross: Piece[]; ret: Piece[] }>();
|
||||
let seq = 0;
|
||||
for (const line of lines) {
|
||||
if (line.direction !== side && line.direction !== returnDir) continue;
|
||||
const bag = groups.get(line.bucketKey) ?? { gross: [], ret: [] };
|
||||
const piece: Piece = {
|
||||
tarikh: line.tarikh,
|
||||
seq: seq++,
|
||||
weight: Math.abs(line.weight),
|
||||
mali: Math.abs(line.mali),
|
||||
count: Math.abs(line.count),
|
||||
percent: line.percent,
|
||||
};
|
||||
if (line.direction === side) bag.gross.push(piece);
|
||||
else bag.ret.push(piece);
|
||||
groups.set(line.bucketKey, bag);
|
||||
}
|
||||
if (!groups.size) return { ...EMPTY_MOVE };
|
||||
|
||||
let grossWeight = 0;
|
||||
let returnWeight = 0;
|
||||
let grossMali = 0;
|
||||
let returnMali = 0;
|
||||
let grossCount = 0;
|
||||
let returnCount = 0;
|
||||
const kept: Piece[] = [];
|
||||
for (const bag of groups.values()) {
|
||||
grossWeight += bag.gross.reduce((s, d) => s + d.weight, 0);
|
||||
returnWeight += bag.ret.reduce((s, d) => s + d.weight, 0);
|
||||
grossMali += bag.gross.reduce((s, d) => s + d.mali, 0);
|
||||
returnMali += bag.ret.reduce((s, d) => s + d.mali, 0);
|
||||
grossCount += bag.gross.reduce((s, d) => s + d.count, 0);
|
||||
returnCount += bag.ret.reduce((s, d) => s + d.count, 0);
|
||||
kept.push(...remainingAfterReturns(bag.gross, bag.ret));
|
||||
}
|
||||
return {
|
||||
grossWeight,
|
||||
returnWeight,
|
||||
netWeight: Math.max(0, grossWeight - returnWeight),
|
||||
grossMali,
|
||||
returnMali,
|
||||
netMali: Math.max(0, grossMali - returnMali),
|
||||
grossCount,
|
||||
returnCount,
|
||||
netCount: Math.max(0, grossCount - returnCount),
|
||||
percent: percentOf(kept),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -13,14 +14,19 @@ 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 { TahesabConnectionsService } from './connections.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
import { TahesabProductLinkService } from './product-link.service';
|
||||
import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import {
|
||||
CreateInTahesabDto,
|
||||
CreateTahesabConnectionDto,
|
||||
ExportPeopleToTahesabDto,
|
||||
ImportBulkFromTahesabDto,
|
||||
ImportBulkProductsFromTahesabDto,
|
||||
ImportFromTahesabDto,
|
||||
@@ -30,11 +36,15 @@ import {
|
||||
PushSanadDto,
|
||||
SearchTahesabDto,
|
||||
SearchTahesabProductsDto,
|
||||
SyncAsnadReportDto,
|
||||
SyncGroupsPageDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
UpdateTahesabInvoiceSettingsDto,
|
||||
} from './dto';
|
||||
|
||||
@Controller('integrations/tahesab')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('tahesab')
|
||||
export class TahesabController {
|
||||
constructor(
|
||||
private readonly connections: TahesabConnectionsService,
|
||||
@@ -42,6 +52,8 @@ export class TahesabController {
|
||||
private readonly products: TahesabProductLinkService,
|
||||
private readonly balance: TahesabBalanceService,
|
||||
private readonly documents: TahesabDocumentsService,
|
||||
private readonly asnadReport: TahesabAsnadReportService,
|
||||
private readonly invoiceSettings: TahesabInvoiceSettingsService,
|
||||
) {}
|
||||
|
||||
// —— Connections ——
|
||||
@@ -216,6 +228,33 @@ export class TahesabController {
|
||||
return this.links.importBulk(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Post('sync/groups')
|
||||
@Roles('owner', 'admin')
|
||||
syncGroups(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: SyncGroupsPageDto,
|
||||
) {
|
||||
return this.links.syncGroupsPage(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Get('sync/unlinked-people')
|
||||
@Roles('owner', 'admin')
|
||||
unlinkedPeople(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId: string,
|
||||
) {
|
||||
return this.links.unlinkedPeople(user.organizationId, connectionId);
|
||||
}
|
||||
|
||||
@Post('sync/export-people')
|
||||
@Roles('owner', 'admin')
|
||||
exportPeople(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: ExportPeopleToTahesabDto,
|
||||
) {
|
||||
return this.links.exportPeople(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Post('sync/products/search')
|
||||
@Roles('owner', 'admin', 'sales')
|
||||
searchProducts(
|
||||
@@ -242,4 +281,157 @@ export class TahesabController {
|
||||
) {
|
||||
return this.products.importBulk(user.organizationId, dto);
|
||||
}
|
||||
|
||||
// —— تنظیمات فاکتور PDF اسناد تهحساب ——
|
||||
@Get('invoice-settings')
|
||||
getInvoiceSettings(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.invoiceSettings.getOrCreate(user.organizationId);
|
||||
}
|
||||
|
||||
@Patch('invoice-settings')
|
||||
@Roles('owner', 'admin')
|
||||
updateInvoiceSettings(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: UpdateTahesabInvoiceSettingsDto,
|
||||
) {
|
||||
return this.invoiceSettings.update(user.organizationId, dto);
|
||||
}
|
||||
|
||||
// —— گزارش فروش اسناد (اسنپشات DoListAsnad) ——
|
||||
@Post('reports/asnad/sync')
|
||||
@Roles('owner', 'admin', 'sales')
|
||||
syncAsnadReport(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: SyncAsnadReportDto,
|
||||
) {
|
||||
return this.asnadReport.startSync(
|
||||
user.organizationId,
|
||||
dto.connectionId,
|
||||
dto.from,
|
||||
dto.to,
|
||||
{ personId: dto.personId, groupIds: dto.groupIds },
|
||||
);
|
||||
}
|
||||
|
||||
@Get('reports/asnad/customers')
|
||||
searchAsnadCustomers(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('q') q?: string,
|
||||
) {
|
||||
return this.asnadReport.searchCustomers(
|
||||
user.organizationId,
|
||||
connectionId,
|
||||
q ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('reports/asnad/sync/:jobId')
|
||||
syncAsnadReportStatus(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('jobId') jobId: string,
|
||||
) {
|
||||
return this.asnadReport.getSyncJob(user.organizationId, jobId);
|
||||
}
|
||||
|
||||
@Get('reports/asnad/overview')
|
||||
asnadReportOverview(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('groupIds') groupIds?: string | string[],
|
||||
@Query('personId') personId?: string,
|
||||
@Query('jensFelez') jensFelez?: string,
|
||||
) {
|
||||
if (!from || !to) {
|
||||
throw new BadRequestException('پارامتر from و to الزامی است.');
|
||||
}
|
||||
return this.asnadReport.overview(user.organizationId, {
|
||||
connectionId,
|
||||
from,
|
||||
to,
|
||||
...this.parseAsnadFilters(groupIds, personId, undefined, jensFelez),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reports/asnad/summary')
|
||||
asnadReportSummary(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('groupIds') groupIds?: string | string[],
|
||||
@Query('personId') personId?: string,
|
||||
@Query('kinds') kinds?: string | string[],
|
||||
@Query('jensFelez') jensFelez?: string,
|
||||
) {
|
||||
if (!from || !to) {
|
||||
throw new BadRequestException('پارامتر from و to الزامی است.');
|
||||
}
|
||||
return this.asnadReport.summary(user.organizationId, {
|
||||
connectionId,
|
||||
from,
|
||||
to,
|
||||
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reports/asnad')
|
||||
listAsnadReport(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('groupIds') groupIds?: string | string[],
|
||||
@Query('personId') personId?: string,
|
||||
@Query('kinds') kinds?: string | string[],
|
||||
@Query('jensFelez') jensFelez?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
if (!from || !to) {
|
||||
throw new BadRequestException('پارامتر from و to الزامی است.');
|
||||
}
|
||||
return this.asnadReport.query(user.organizationId, {
|
||||
connectionId,
|
||||
from,
|
||||
to,
|
||||
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private parseAsnadFilters(
|
||||
groupIds?: string | string[],
|
||||
personId?: string,
|
||||
kinds?: string | string[],
|
||||
jensFelez?: string,
|
||||
) {
|
||||
const groups = Array.isArray(groupIds)
|
||||
? groupIds
|
||||
: groupIds
|
||||
? groupIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const kindList = Array.isArray(kinds)
|
||||
? kinds
|
||||
: kinds
|
||||
? kinds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const jf =
|
||||
jensFelez != null && jensFelez !== '' ? Number(jensFelez) : undefined;
|
||||
return {
|
||||
groupIds: groups,
|
||||
personId,
|
||||
kinds: kindList,
|
||||
jensFelez: jf != null && Number.isFinite(jf) ? jf : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
import { TahesabProductLinkService } from './product-link.service';
|
||||
import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, CustomerGroupsModule],
|
||||
controllers: [TahesabController],
|
||||
providers: [
|
||||
TahesabCryptoService,
|
||||
@@ -24,6 +27,8 @@ import { TahesabController } from './tahesab.controller';
|
||||
TahesabProductLinkService,
|
||||
TahesabBalanceService,
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
],
|
||||
exports: [
|
||||
TahesabCryptoService,
|
||||
@@ -32,6 +37,8 @@ import { TahesabController } from './tahesab.controller';
|
||||
TahesabProductLinkService,
|
||||
TahesabBalanceService,
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
],
|
||||
})
|
||||
export class TahesabModule {}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -18,7 +19,8 @@ import { InventoryService } from './inventory.service';
|
||||
type AuthUser = { organizationId: string };
|
||||
|
||||
@Controller('inventory')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
|
||||
@RequirePermission('products')
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventory: InventoryService) {}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
@@ -22,7 +23,8 @@ import { InvoicesService } from './invoices.service';
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('invoices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('billing')
|
||||
export class InvoicesController {
|
||||
constructor(private readonly invoicesService: InvoicesService) {}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ const MAP_TO = [
|
||||
'lead.trade',
|
||||
'lead.notes',
|
||||
'lead.source',
|
||||
'lead.gender',
|
||||
'person.gender',
|
||||
'notes',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
@@ -13,9 +13,11 @@ import type {
|
||||
SubmitLeadFormResponse,
|
||||
UpdateLeadFormRequest,
|
||||
} from '@mento/shared';
|
||||
import { isGenderQuestionLabel, parsePersonGenderAnswer } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import {
|
||||
@@ -83,6 +85,7 @@ export class LeadFormsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly storage: StorageQuotaService,
|
||||
private readonly automations: AutomationsService,
|
||||
private readonly messageAutomations: MessageAutomationsService,
|
||||
private readonly leadStages: LeadStagesService,
|
||||
@@ -210,7 +213,12 @@ export class LeadFormsService {
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.findInOrg(organizationId, id);
|
||||
const row = await this.findInOrg(organizationId, id);
|
||||
if (row.coverStorageKey) {
|
||||
const bytes = await this.files.sizeOf(row.coverStorageKey);
|
||||
await this.files.remove(row.coverStorageKey);
|
||||
if (bytes) await this.storage.addLocal(organizationId, -bytes);
|
||||
}
|
||||
await this.prisma.leadForm.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -391,6 +399,7 @@ export class LeadFormsService {
|
||||
sourceId: form.id,
|
||||
sourceType: 'lead_form',
|
||||
leadId,
|
||||
syncLeadAssignee: !ownerId && leadCreated && !!leadId,
|
||||
mobile: mapped.mobile ?? mapped.phone ?? null,
|
||||
formName: form.name,
|
||||
formValues: values,
|
||||
@@ -547,6 +556,7 @@ export class LeadFormsService {
|
||||
trade: emptyToNull(mapped.trade) ?? existingLead.trade,
|
||||
source: source ?? existingLead.source,
|
||||
notes: emptyToNull(mapped.notes) ?? existingLead.notes,
|
||||
...(mapped.gender ? { gender: mapped.gender } : {}),
|
||||
assigneeId: existingLead.assigneeId ?? assigneeId,
|
||||
lastContactAt: new Date(),
|
||||
},
|
||||
@@ -595,6 +605,7 @@ export class LeadFormsService {
|
||||
trade: emptyToNull(mapped.trade),
|
||||
source,
|
||||
notes: emptyToNull(mapped.notes),
|
||||
gender: mapped.gender ?? 'none',
|
||||
stageId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
assigneeId,
|
||||
@@ -654,6 +665,7 @@ export class LeadFormsService {
|
||||
phone: phone ?? existing.phone,
|
||||
position: emptyToNull(mapped.position) ?? existing.position,
|
||||
notes: emptyToNull(mapped.notes) ?? existing.notes,
|
||||
...(mapped.gender ? { gender: mapped.gender } : {}),
|
||||
ownerId: existing.ownerId ?? ownerId,
|
||||
},
|
||||
});
|
||||
@@ -670,6 +682,7 @@ export class LeadFormsService {
|
||||
phone,
|
||||
position: emptyToNull(mapped.position),
|
||||
notes: emptyToNull(mapped.notes),
|
||||
gender: mapped.gender ?? 'none',
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
@@ -829,6 +842,9 @@ export class LeadFormsService {
|
||||
throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد');
|
||||
}
|
||||
const key = `lead-forms/${id}/cover.${ext}`;
|
||||
const prevBytes = row.coverStorageKey ? await this.files.sizeOf(row.coverStorageKey) : 0;
|
||||
const delta = file.buffer.length - prevBytes;
|
||||
if (delta > 0) await this.storage.assertCanAdd(organizationId, delta);
|
||||
await this.files.save(key, file.buffer);
|
||||
if (row.coverStorageKey && row.coverStorageKey !== key) {
|
||||
await this.files.remove(row.coverStorageKey);
|
||||
@@ -837,12 +853,17 @@ export class LeadFormsService {
|
||||
where: { id },
|
||||
data: { coverStorageKey: key },
|
||||
});
|
||||
if (delta) await this.storage.addLocal(organizationId, delta);
|
||||
return this.get(organizationId, id);
|
||||
}
|
||||
|
||||
async removeCover(organizationId: string, id: string) {
|
||||
const row = await this.findInOrg(organizationId, id);
|
||||
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey);
|
||||
if (row.coverStorageKey) {
|
||||
const bytes = await this.files.sizeOf(row.coverStorageKey);
|
||||
await this.files.remove(row.coverStorageKey);
|
||||
if (bytes) await this.storage.addLocal(organizationId, -bytes);
|
||||
}
|
||||
await this.prisma.leadForm.update({
|
||||
where: { id },
|
||||
data: { coverStorageKey: null },
|
||||
@@ -925,6 +946,7 @@ type MappedValues = {
|
||||
region?: string;
|
||||
trade?: string;
|
||||
leadSource?: string;
|
||||
gender?: 'male' | 'female' | 'none';
|
||||
};
|
||||
|
||||
function mapValues(
|
||||
@@ -945,6 +967,15 @@ function mapValues(
|
||||
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
|
||||
}
|
||||
if (!raw) continue;
|
||||
if (
|
||||
field.mapTo === 'lead.gender' ||
|
||||
field.mapTo === 'person.gender' ||
|
||||
isGenderQuestionLabel(field.label)
|
||||
) {
|
||||
const gender = parsePersonGenderAnswer(raw);
|
||||
if (gender) mapped.gender = gender;
|
||||
continue;
|
||||
}
|
||||
switch (field.mapTo) {
|
||||
case 'person.fullName': {
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
@@ -1068,7 +1099,8 @@ function collectExtras(
|
||||
for (const field of fields) {
|
||||
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
|
||||
const raw = (values?.[field.key] ?? '').toString().trim();
|
||||
if (raw) lines.push(`${field.label}: ${raw}`);
|
||||
if (!raw || (isGenderQuestionLabel(field.label) && parsePersonGenderAnswer(raw))) continue;
|
||||
lines.push(`${field.label}: ${raw}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { PERSON_GENDERS } from '@mento/shared';
|
||||
|
||||
export class LeadSmsDto {
|
||||
@IsString()
|
||||
@@ -26,6 +27,8 @@ export class CreateLeadDto {
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional() @IsString() contactName?: string;
|
||||
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
|
||||
@IsOptional() @IsBoolean() showGenderInName?: boolean;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() region?: string;
|
||||
@@ -47,6 +50,8 @@ export class UpdateLeadDto {
|
||||
@IsOptional() @IsString() @MinLength(1) title?: string;
|
||||
@IsOptional() @IsString() @MinLength(5) mobile?: string;
|
||||
@IsOptional() @IsString() contactName?: string | null;
|
||||
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
|
||||
@IsOptional() @IsBoolean() showGenderInName?: boolean;
|
||||
@IsOptional() @IsString() phone?: string | null;
|
||||
@IsOptional() @IsString() city?: string | null;
|
||||
@IsOptional() @IsString() region?: string | null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { LeadStageDto } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const DEFAULT_STAGES: Array<{
|
||||
@@ -74,20 +75,24 @@ export class LeadStagesService {
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const row = await this.prisma.leadStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
color: dto.color?.trim() || 'teal',
|
||||
showOnBoard: dto.showOnBoard !== false,
|
||||
isConverted: Boolean(dto.isConverted),
|
||||
isLost: Boolean(dto.isLost),
|
||||
isUnqualified: Boolean(dto.isUnqualified),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
try {
|
||||
const row = await this.prisma.leadStage.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: this.cleanName(dto.name),
|
||||
color: dto.color?.trim() || 'teal',
|
||||
showOnBoard: dto.showOnBoard !== false,
|
||||
isConverted: Boolean(dto.isConverted),
|
||||
isLost: Boolean(dto.isLost),
|
||||
isUnqualified: Boolean(dto.isUnqualified),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
},
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
this.rethrow(e);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -103,10 +108,11 @@ export class LeadStagesService {
|
||||
position?: number;
|
||||
},
|
||||
): Promise<LeadStageDto> {
|
||||
try {
|
||||
await this.prisma.leadStage.updateMany({
|
||||
where: { id, organizationId },
|
||||
data: {
|
||||
...(dto.name != null ? { name: dto.name.trim() } : {}),
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: dto.color } : {}),
|
||||
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
|
||||
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
|
||||
@@ -122,6 +128,9 @@ export class LeadStagesService {
|
||||
include: { _count: { select: { leads: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
this.rethrow(e);
|
||||
}
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
|
||||
@@ -143,11 +152,27 @@ export class LeadStagesService {
|
||||
});
|
||||
if (!stage) return;
|
||||
if (stage._count.leads > 0) {
|
||||
throw new Error('مرحلهای که لید دارد قابل حذف نیست');
|
||||
throw new BadRequestException(
|
||||
'این ستون هنوز لید دارد و حذف نمیشود. لیدهای فعال و بایگانی را اول به ستون دیگری ببرید.',
|
||||
);
|
||||
}
|
||||
await this.prisma.leadStage.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private cleanName(raw: string): string {
|
||||
const name = raw?.trim() ?? '';
|
||||
if (!name) throw new BadRequestException('نام ستون لازم است');
|
||||
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
|
||||
return name;
|
||||
}
|
||||
|
||||
private rethrow(e: unknown): never {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('ستونی با این نام وجود دارد');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -46,10 +46,12 @@ import {
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { LeadsImportService } from './leads-import.service';
|
||||
import { LeadsService } from './leads.service';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
|
||||
@Controller('leads')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('leads')
|
||||
@RequirePermission('leads', { reads: true })
|
||||
export class LeadsController {
|
||||
constructor(
|
||||
private readonly leads: LeadsService,
|
||||
@@ -105,6 +107,7 @@ export class LeadsController {
|
||||
await this.stages.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
if (e instanceof BadRequestException) throw e;
|
||||
throw new BadRequestException(
|
||||
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
|
||||
);
|
||||
@@ -299,8 +302,6 @@ export class LeadsController {
|
||||
}
|
||||
|
||||
@Post('assign')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
|
||||
return this.leads
|
||||
.assign(user, dto.leadIds, dto.assigneeId)
|
||||
|
||||
@@ -20,15 +20,18 @@ import type {
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||
import { applyLeadGenderName, applyPersonGenderTitle } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
import { leadsDeepWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
elevated,
|
||||
forScope,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import {
|
||||
asAddresses,
|
||||
collectAddresses,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
} from '../common/custom-fields';
|
||||
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
@@ -59,18 +63,21 @@ function mobileKey(raw: string | null | undefined): string | null {
|
||||
return d.startsWith('0') ? d : d;
|
||||
}
|
||||
|
||||
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
|
||||
const actor = forScope(baseActor, 'leads');
|
||||
if (elevated(actor.role)) return {};
|
||||
if (actor.dataVisibility === 'org') return {};
|
||||
if (
|
||||
actor.dataVisibility === 'team' &&
|
||||
actor.visibleUserIds &&
|
||||
actor.visibleUserIds.length
|
||||
) {
|
||||
return { assigneeId: { in: actor.visibleUserIds } };
|
||||
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
|
||||
if (actor.leadsAccess !== undefined) {
|
||||
return leadsDeepWhere(actor);
|
||||
}
|
||||
return { assigneeId: actor.userId };
|
||||
const scoped = forScope(actor, 'leads');
|
||||
if (elevated(scoped.role)) return {};
|
||||
if (scoped.dataVisibility === 'org') return {};
|
||||
if (
|
||||
scoped.dataVisibility === 'team' &&
|
||||
scoped.visibleUserIds &&
|
||||
scoped.visibleUserIds.length
|
||||
) {
|
||||
return { assigneeId: { in: scoped.visibleUserIds } };
|
||||
}
|
||||
return { assigneeId: scoped.userId };
|
||||
}
|
||||
|
||||
const leadInclude = {
|
||||
@@ -86,6 +93,7 @@ export class LeadsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly storage: StorageQuotaService,
|
||||
private readonly game: GamificationService,
|
||||
private readonly sms: SmsGatewayService,
|
||||
private readonly outboundSms: OutboundSmsLogService,
|
||||
@@ -337,6 +345,8 @@ export class LeadsService {
|
||||
title: string;
|
||||
mobile: string;
|
||||
contactName?: string | null;
|
||||
gender?: string | null;
|
||||
showGenderInName?: boolean | null;
|
||||
phone?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -370,19 +380,31 @@ export class LeadsService {
|
||||
const stageId = dto.stageId || stages[0]?.id;
|
||||
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
||||
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'leads');
|
||||
let assigneeId: string | null = null;
|
||||
if (elevated(actor.role) && dto.assigneeId) {
|
||||
if (allowOthers && dto.assigneeId) {
|
||||
assigneeId = dto.assigneeId;
|
||||
} else if (!elevated(actor.role)) {
|
||||
} else if (!allowOthers) {
|
||||
assigneeId = actor.userId;
|
||||
}
|
||||
|
||||
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
|
||||
const named = applyLeadGenderName({
|
||||
title: dto.title,
|
||||
contactName: dto.contactName,
|
||||
gender:
|
||||
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
|
||||
? dto.gender
|
||||
: undefined,
|
||||
showGenderInName: dto.showGenderInName,
|
||||
});
|
||||
const row = await this.prisma.lead.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title: dto.title.trim(),
|
||||
contactName: emptyToNull(dto.contactName),
|
||||
title: named.title,
|
||||
contactName: named.contactName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile,
|
||||
phone: emptyToNull(dto.phone),
|
||||
city: emptyToNull(dto.city),
|
||||
@@ -457,12 +479,41 @@ export class LeadsService {
|
||||
}
|
||||
}
|
||||
|
||||
const touchName =
|
||||
typeof dto.title === 'string' ||
|
||||
dto.contactName !== undefined ||
|
||||
dto.gender !== undefined ||
|
||||
dto.showGenderInName !== undefined;
|
||||
const named = touchName
|
||||
? applyLeadGenderName({
|
||||
title: typeof dto.title === 'string' ? dto.title : existing.title,
|
||||
contactName:
|
||||
dto.contactName !== undefined
|
||||
? (dto.contactName as string | null)
|
||||
: existing.contactName,
|
||||
gender:
|
||||
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
|
||||
? dto.gender
|
||||
: existing.gender === 'male' || existing.gender === 'female'
|
||||
? existing.gender
|
||||
: 'none',
|
||||
showGenderInName:
|
||||
typeof dto.showGenderInName === 'boolean'
|
||||
? dto.showGenderInName
|
||||
: existing.showGenderInName,
|
||||
})
|
||||
: null;
|
||||
|
||||
const data: Prisma.LeadUpdateInput = {
|
||||
...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}),
|
||||
mobile,
|
||||
...(dto.contactName !== undefined
|
||||
? { contactName: emptyToNull(dto.contactName as string) }
|
||||
...(named
|
||||
? {
|
||||
title: named.title,
|
||||
contactName: named.contactName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
mobile,
|
||||
...(dto.phone !== undefined
|
||||
? { phone: emptyToNull(dto.phone as string) }
|
||||
: {}),
|
||||
@@ -521,8 +572,9 @@ export class LeadsService {
|
||||
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
|
||||
data.stage = { connect: { id: dto.stageId } };
|
||||
}
|
||||
if (elevated(actor.role) && dto.assigneeId !== undefined) {
|
||||
if (dto.assigneeId !== undefined) {
|
||||
const aid = dto.assigneeId ? String(dto.assigneeId) : null;
|
||||
await assertAssigneeSet(this.prisma, actor, 'leads', [aid], [existing.assigneeId]);
|
||||
data.assignee = aid
|
||||
? { connect: { id: aid } }
|
||||
: { disconnect: true };
|
||||
@@ -592,9 +644,7 @@ export class LeadsService {
|
||||
leadIds: string[],
|
||||
assigneeId: string,
|
||||
): Promise<number> {
|
||||
if (!elevated(actor.role)) {
|
||||
throw new BadRequestException('فقط مدیر میتواند تخصیص دهد');
|
||||
}
|
||||
await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]);
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: assigneeId,
|
||||
@@ -760,14 +810,14 @@ export class LeadsService {
|
||||
? `تماس ثبت شد — ${dto.outcome}`
|
||||
: 'تماس ثبت شد';
|
||||
await this.addEvent(actor, id, 'call', title, dto.body ?? null);
|
||||
const followAt = this.parseFollowUpAt(dto.nextFollowUpAt);
|
||||
if (followAt) {
|
||||
await this.createLeadFollowUpActivity(actor, before, followAt, dto.body);
|
||||
}
|
||||
const data: Prisma.LeadUpdateInput = {
|
||||
lastContactAt: new Date(),
|
||||
...(dto.nextFollowUpAt !== undefined
|
||||
? {
|
||||
nextFollowUpAt: dto.nextFollowUpAt
|
||||
? new Date(dto.nextFollowUpAt)
|
||||
: null,
|
||||
}
|
||||
? { nextFollowUpAt: followAt }
|
||||
: {}),
|
||||
};
|
||||
if (dto.stageId) data.stage = { connect: { id: dto.stageId } };
|
||||
@@ -806,8 +856,10 @@ export class LeadsService {
|
||||
nextFollowUpAt: string,
|
||||
body?: string,
|
||||
): Promise<LeadDto> {
|
||||
await this.findAccessible(actor, id);
|
||||
const at = new Date(nextFollowUpAt);
|
||||
const lead = await this.findAccessible(actor, id);
|
||||
const at = this.parseFollowUpAt(nextFollowUpAt);
|
||||
if (!at) throw new BadRequestException('زمان پیگیری نامعتبر است');
|
||||
await this.createLeadFollowUpActivity(actor, lead, at, body);
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
@@ -828,6 +880,39 @@ export class LeadsService {
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
/** پیگیری آینده باید خودش یک فعالیت باز و متصل به لید باشد، نه فقط یک تاریخ. */
|
||||
private parseFollowUpAt(value?: string | null): Date | null {
|
||||
if (!value) return null;
|
||||
const at = new Date(value);
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
throw new BadRequestException('زمان پیگیری نامعتبر است');
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
private async createLeadFollowUpActivity(
|
||||
actor: Actor,
|
||||
lead: { id: string; title: string; assigneeId: string | null },
|
||||
dueAt: Date,
|
||||
body?: string | null,
|
||||
) {
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'activities');
|
||||
const assigneeId = allowOthers && lead.assigneeId ? lead.assigneeId : actor.userId;
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
type: 'task',
|
||||
title: `پیگیری: ${lead.title}`.slice(0, 200),
|
||||
description: body?.trim() || null,
|
||||
dueAt,
|
||||
leadId: lead.id,
|
||||
assigneeId,
|
||||
assignees: { connect: [{ id: assigneeId }] },
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async archive(actor: Actor, ids: string[]): Promise<number> {
|
||||
if (!elevated(actor.role) && ids.length > 1) {
|
||||
throw new BadRequestException('حذف گروهی فقط برای مدیر');
|
||||
@@ -873,13 +958,24 @@ export class LeadsService {
|
||||
? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim()
|
||||
: (lead.contactName || lead.title).trim();
|
||||
const nameParts = rawName.split(/\s+/).filter(Boolean);
|
||||
const firstName = nameParts[0] || lead.title;
|
||||
const lastName = nameParts.slice(1).join(' ') || '—';
|
||||
const bareFirst = nameParts[0] || lead.title;
|
||||
const bareLast = nameParts.slice(1).join(' ');
|
||||
const named = applyPersonGenderTitle({
|
||||
firstName: bareFirst,
|
||||
lastName: bareLast,
|
||||
gender:
|
||||
lead.gender === 'male' || lead.gender === 'female' || lead.gender === 'none'
|
||||
? lead.gender
|
||||
: 'none',
|
||||
showGenderInName: lead.showGenderInName,
|
||||
});
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
firstName,
|
||||
lastName,
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile: lead.mobile,
|
||||
phone: lead.phone,
|
||||
instagram: lead.instagram,
|
||||
@@ -975,8 +1071,10 @@ export class LeadsService {
|
||||
const stages = await this.stages.list(actor.organizationId);
|
||||
const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id);
|
||||
const lostIds = stages.filter((s) => s.isLost).map((s) => s.id);
|
||||
const interested = stages.find((s) => s.name.includes('علاقهمند'));
|
||||
const newStage = stages.find((s) => s.name === 'جدید');
|
||||
// همان مقصد فرم و ورود از اکسل: اولین ستون باز، مستقل از اسم «جدید»
|
||||
const newStage = stages.find(
|
||||
(s) => !s.isConverted && !s.isLost && !s.isUnqualified,
|
||||
);
|
||||
|
||||
const [
|
||||
total,
|
||||
@@ -984,7 +1082,6 @@ export class LeadsService {
|
||||
unassigned,
|
||||
converted,
|
||||
lost,
|
||||
interestedCount,
|
||||
withFollowUp,
|
||||
callsLogged,
|
||||
byAssigneeRaw,
|
||||
@@ -1004,11 +1101,6 @@ export class LeadsService {
|
||||
where: { ...base, stageId: { in: lostIds } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
interested
|
||||
? this.prisma.lead.count({
|
||||
where: { ...base, stageId: interested.id },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, nextFollowUpAt: { not: null } },
|
||||
}),
|
||||
@@ -1051,7 +1143,7 @@ export class LeadsService {
|
||||
.sort((a, b) => b.count - a.count),
|
||||
callsLogged,
|
||||
withFollowUp,
|
||||
interested: interestedCount,
|
||||
interested: 0,
|
||||
converted,
|
||||
lost,
|
||||
conversionRate: Math.round((converted / denom) * 1000) / 10,
|
||||
@@ -1202,6 +1294,8 @@ export class LeadsService {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
contactName: row.contactName,
|
||||
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
|
||||
showGenderInName: row.showGenderInName,
|
||||
mobile: row.mobile,
|
||||
phone: row.phone,
|
||||
city: row.city,
|
||||
@@ -1252,6 +1346,9 @@ export class LeadsService {
|
||||
const storageKey = path
|
||||
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
|
||||
const delta = file.size - prevBytes;
|
||||
if (delta > 0) await this.storage.assertCanAdd(actor.organizationId, delta);
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
if (prevKey && prevKey !== storageKey) {
|
||||
await this.files.remove(prevKey);
|
||||
@@ -1262,13 +1359,16 @@ export class LeadsService {
|
||||
include: leadInclude,
|
||||
});
|
||||
await this.addEvent(actor, id, 'note', 'لوگو بهروز شد', null);
|
||||
if (delta) await this.storage.addLocal(actor.organizationId, delta);
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
|
||||
const existing = await this.findAccessible(actor, id);
|
||||
if (existing.logoStorageKey) {
|
||||
const bytes = await this.files.sizeOf(existing.logoStorageKey);
|
||||
await this.files.remove(existing.logoStorageKey);
|
||||
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
|
||||
}
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
|
||||
+13
-1
@@ -1,11 +1,23 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { json, urlencoded } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import { brand } from '@mento/shared';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
// bodyParser خاموش تا سقف ۲ مگ برای پیوست فشردهٔ فرم عمومی + حفظ rawBody برای وبهوک
|
||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||
app.use(
|
||||
json({
|
||||
limit: '2mb',
|
||||
verify: (req: Request, _res, buf) => {
|
||||
(req as Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(urlencoded({ extended: true, limit: '2mb' }));
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
@@ -98,7 +98,7 @@ export class MembershipsService {
|
||||
where: { organizationId, userId },
|
||||
include: {
|
||||
user: true,
|
||||
orgRole: { select: { id: true, name: true, color: true } },
|
||||
orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export class MembershipsService {
|
||||
where: { organizationId },
|
||||
include: {
|
||||
user: true,
|
||||
orgRole: { select: { id: true, name: true, color: true } },
|
||||
orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
|
||||
},
|
||||
orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }],
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ function testTemplate() {
|
||||
}) === 'سلام علی 0912',
|
||||
'name phone vars',
|
||||
);
|
||||
assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip');
|
||||
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
|
||||
}
|
||||
|
||||
testDelay();
|
||||
|
||||
@@ -17,7 +17,20 @@ export const MESSAGE_AUTOMATION_TRIGGERS = [
|
||||
] as const;
|
||||
|
||||
export const MAX_MESSAGE_REPEAT = 10;
|
||||
export const MAX_MESSAGE_SMS = 160;
|
||||
|
||||
/** شمارهٔ مشتری: اول شخص، و اگر نبود شمارهٔ خود سرنخ. */
|
||||
export function customerPhone(
|
||||
person: { mobile?: string | null; phone?: string | null } | null | undefined,
|
||||
lead: { mobile?: string | null; phone?: string | null } | null | undefined,
|
||||
): string {
|
||||
return (
|
||||
person?.mobile?.trim() ||
|
||||
person?.phone?.trim() ||
|
||||
lead?.mobile?.trim() ||
|
||||
lead?.phone?.trim() ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function delayMs(value: number, unit: MessageDelayUnit): number {
|
||||
const n = Math.max(0, Math.trunc(Number(value) || 0));
|
||||
@@ -92,10 +105,9 @@ export function applyMessageTemplate(
|
||||
});
|
||||
}
|
||||
|
||||
/** متن کامل پیامک؛ بخشبندی و هزینه در درگاه حساب میشود. */
|
||||
export function clipSms(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= MAX_MESSAGE_SMS) return trimmed;
|
||||
return `${trimmed.slice(0, MAX_MESSAGE_SMS - 1)}…`;
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/** When this event happens, cancel leftover messages of these earlier triggers. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildSendAts,
|
||||
cancelTriggersFor,
|
||||
clipSms,
|
||||
customerPhone,
|
||||
} from './message-automation.util';
|
||||
|
||||
export type MessageAutomationContext = {
|
||||
@@ -354,13 +355,15 @@ export class MessageAutomationsService {
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const lead = await this.lookupLead(job);
|
||||
const personName = person
|
||||
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
|
||||
: '';
|
||||
const phone = customerPhone(person, lead);
|
||||
const vars = {
|
||||
title: job.entityTitle,
|
||||
name: personName || job.entityTitle,
|
||||
phone: person?.mobile || person?.phone || '',
|
||||
name: personName || lead?.contactName?.trim() || lead?.title || job.entityTitle,
|
||||
phone,
|
||||
date: formatJalaliDayFa(new Date()),
|
||||
company: person?.company?.name ?? '',
|
||||
};
|
||||
@@ -405,26 +408,59 @@ export class MessageAutomationsService {
|
||||
}
|
||||
|
||||
if (targetPerson && wantSms) {
|
||||
await this.sendSms(person?.mobile || person?.phone, smsText, {
|
||||
if (!phone) {
|
||||
throw new Error('شماره مشتری یا سرنخ برای پیامک پیدا نشد');
|
||||
}
|
||||
const sent = await this.sendSms(phone, smsText, {
|
||||
organizationId: job.organizationId,
|
||||
personId: job.personId,
|
||||
leadId: lead?.id ?? null,
|
||||
ruleId: job.ruleId || job.rule.id || '',
|
||||
});
|
||||
if (!sent) {
|
||||
throw new Error('ارسال پیامک به مشتری یا سرنخ ناموفق بود');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** سرنخ همان پرونده، یا سرنخی که به این شخص تبدیل شده. */
|
||||
private async lookupLead(job: {
|
||||
organizationId: string;
|
||||
entityId: string;
|
||||
personId: string | null;
|
||||
}) {
|
||||
return this.prisma.lead.findFirst({
|
||||
where: {
|
||||
organizationId: job.organizationId,
|
||||
OR: [
|
||||
{ id: job.entityId },
|
||||
...(job.personId ? [{ convertedPersonId: job.personId }] : []),
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
mobile: true,
|
||||
phone: true,
|
||||
contactName: true,
|
||||
title: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async sendSms(
|
||||
mobile: string | null | undefined,
|
||||
text: string,
|
||||
log: {
|
||||
organizationId: string;
|
||||
personId?: string | null;
|
||||
leadId?: string | null;
|
||||
ruleId: string;
|
||||
},
|
||||
) {
|
||||
if (!mobile) {
|
||||
): Promise<boolean> {
|
||||
if (!mobile?.trim()) {
|
||||
this.logger.warn('Message automation SMS skipped: no mobile');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const result = await this.sms.send({
|
||||
organizationId: log.organizationId,
|
||||
@@ -432,10 +468,11 @@ export class MessageAutomationsService {
|
||||
text,
|
||||
source: 'automation',
|
||||
});
|
||||
if (log.personId) {
|
||||
if (log.personId || log.leadId) {
|
||||
await this.outboundSms.record({
|
||||
organizationId: log.organizationId,
|
||||
personId: log.personId,
|
||||
leadId: log.leadId,
|
||||
mobile,
|
||||
body: text,
|
||||
source: 'automation',
|
||||
@@ -448,7 +485,9 @@ export class MessageAutomationsService {
|
||||
this.logger.warn(
|
||||
`Message automation SMS failed: ${result.reason ?? 'unknown'}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private toData(
|
||||
|
||||
+77
-19
@@ -5,6 +5,7 @@ import {
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
@@ -15,19 +16,6 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
const NOTE_TYPES = [
|
||||
'general',
|
||||
'call_result',
|
||||
'meeting',
|
||||
'important',
|
||||
'warning',
|
||||
'next_action',
|
||||
'follow_up',
|
||||
'idea',
|
||||
'suggestion',
|
||||
'personal',
|
||||
] as const;
|
||||
|
||||
const NOTE_VISIBILITIES = ['team', 'personal'] as const;
|
||||
|
||||
const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const;
|
||||
@@ -73,8 +61,10 @@ export class ListNotesQueryDto {
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_TYPES)
|
||||
noteType?: (typeof NOTE_TYPES)[number];
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
noteType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_VISIBILITIES)
|
||||
@@ -110,8 +100,10 @@ export class CreateNoteDto {
|
||||
body!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_TYPES)
|
||||
noteType?: (typeof NOTE_TYPES)[number];
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
noteType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_VISIBILITIES)
|
||||
@@ -171,6 +163,10 @@ export class CreateNoteDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
activityId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpdateNoteDto {
|
||||
@@ -187,8 +183,10 @@ export class UpdateNoteDto {
|
||||
body?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_TYPES)
|
||||
noteType?: (typeof NOTE_TYPES)[number];
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
noteType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_VISIBILITIES)
|
||||
@@ -248,6 +246,10 @@ export class UpdateNoteDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
activityId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class NoteAiNextActionDtoClass {
|
||||
@@ -300,3 +302,59 @@ export class ApplyNoteAiSuggestionDto {
|
||||
@Type(() => ApplyMemoryEditDto)
|
||||
memoryItems?: ApplyMemoryEditDto[];
|
||||
}
|
||||
|
||||
const NOTE_CATEGORY_TONES = [
|
||||
'general',
|
||||
'follow_up',
|
||||
'idea',
|
||||
'suggestion',
|
||||
'meeting',
|
||||
'personal',
|
||||
] as const;
|
||||
|
||||
export class CreateNoteCategoryDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_CATEGORY_TONES)
|
||||
tone?: (typeof NOTE_CATEGORY_TONES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showInBar?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateNoteCategoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(NOTE_CATEGORY_TONES)
|
||||
tone?: (typeof NOTE_CATEGORY_TONES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showInBar?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class ReorderNoteCategoriesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { NoteCategoryDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type {
|
||||
CreateNoteCategoryDto,
|
||||
UpdateNoteCategoryDto,
|
||||
} from './dto';
|
||||
|
||||
const DEFAULTS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
tone: string;
|
||||
showInBar: boolean;
|
||||
}> = [
|
||||
{ key: 'general', label: 'معمولی', tone: 'general', showInBar: true },
|
||||
{ key: 'follow_up', label: 'پیگیری', tone: 'follow_up', showInBar: true },
|
||||
{ key: 'idea', label: 'ایده', tone: 'idea', showInBar: true },
|
||||
{ key: 'suggestion', label: 'پیشنهاد', tone: 'suggestion', showInBar: true },
|
||||
{ key: 'meeting', label: 'جلسه', tone: 'meeting', showInBar: true },
|
||||
{ key: 'personal', label: 'شخصی', tone: 'personal', showInBar: true },
|
||||
{ key: 'call_result', label: 'نتیجه تماس', tone: 'follow_up', showInBar: false },
|
||||
{ key: 'important', label: 'نکته مهم', tone: 'suggestion', showInBar: false },
|
||||
{ key: 'warning', label: 'هشدار', tone: 'follow_up', showInBar: false },
|
||||
{ key: 'next_action', label: 'کار بعدی', tone: 'follow_up', showInBar: false },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class NoteCategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(organizationId: string): Promise<NoteCategoryDto[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const rows = await this.prisma.noteCategory.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateNoteCategoryDto,
|
||||
): Promise<NoteCategoryDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const label = dto.label.trim();
|
||||
if (!label) throw new BadRequestException('عنوان دسته لازم است');
|
||||
const max = await this.prisma.noteCategory.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const row = await this.prisma.noteCategory.create({
|
||||
data: {
|
||||
organizationId,
|
||||
key: `cat_${Date.now().toString(36)}`,
|
||||
label,
|
||||
tone: dto.tone ?? 'general',
|
||||
showInBar: dto.showInBar ?? true,
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
isSystem: false,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateNoteCategoryDto,
|
||||
): Promise<NoteCategoryDto> {
|
||||
const existing = await this.prisma.noteCategory.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('دسته پیدا نشد');
|
||||
const label = dto.label?.trim();
|
||||
if (dto.label !== undefined && !label) {
|
||||
throw new BadRequestException('عنوان دسته لازم است');
|
||||
}
|
||||
const row = await this.prisma.noteCategory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(label ? { label } : {}),
|
||||
...(dto.tone !== undefined ? { tone: dto.tone } : {}),
|
||||
...(dto.showInBar !== undefined ? { showInBar: dto.showInBar } : {}),
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
...(dto.position !== undefined ? { position: dto.position } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async reorder(organizationId: string, ids: string[]): Promise<NoteCategoryDto[]> {
|
||||
const rows = await this.prisma.noteCategory.findMany({
|
||||
where: { organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
const known = new Set(rows.map((row) => row.id));
|
||||
const ordered = ids.filter((id) => known.has(id));
|
||||
await this.prisma.$transaction(
|
||||
ordered.map((id, index) =>
|
||||
this.prisma.noteCategory.update({
|
||||
where: { id },
|
||||
data: { position: index },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return this.list(organizationId);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
const existing = await this.prisma.noteCategory.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('دسته پیدا نشد');
|
||||
if (existing.isSystem) {
|
||||
throw new BadRequestException('دستهٔ پیشفرض حذف نمیشود؛ میتوانید از نوار پنهانش کنید');
|
||||
}
|
||||
await this.prisma.noteCategory.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureDefaults(organizationId: string) {
|
||||
const count = await this.prisma.noteCategory.count({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (count > 0) return;
|
||||
await this.prisma.noteCategory.createMany({
|
||||
data: DEFAULTS.map((item, index) => ({
|
||||
organizationId,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
tone: item.tone,
|
||||
position: index,
|
||||
showInBar: item.showInBar,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
tone: string;
|
||||
position: number;
|
||||
isActive: boolean;
|
||||
showInBar: boolean;
|
||||
isSystem: boolean;
|
||||
}): NoteCategoryDto {
|
||||
return {
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
tone: row.tone,
|
||||
position: row.position,
|
||||
isActive: row.isActive,
|
||||
showInBar: row.showInBar,
|
||||
isSystem: row.isSystem,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,21 @@ import {
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
|
||||
import {
|
||||
CreateNoteDto,
|
||||
ListNotesQueryDto,
|
||||
UpdateNoteDto,
|
||||
ApplyNoteAiSuggestionDto,
|
||||
CreateNoteCategoryDto,
|
||||
UpdateNoteCategoryDto,
|
||||
ReorderNoteCategoriesDto,
|
||||
} from './dto';
|
||||
import { NoteAiService } from './note-ai.service';
|
||||
import { NoteCategoriesService } from './note-categories.service';
|
||||
import { NotesService } from './notes.service';
|
||||
import type { DataVisibility } from '@mento/shared';
|
||||
|
||||
@@ -30,13 +42,59 @@ type AuthUser = {
|
||||
};
|
||||
|
||||
@Controller('notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('notes')
|
||||
export class NotesController {
|
||||
constructor(
|
||||
private readonly notesService: NotesService,
|
||||
private readonly noteAi: NoteAiService,
|
||||
private readonly categories: NoteCategoriesService,
|
||||
) {}
|
||||
|
||||
@Get('categories')
|
||||
listCategories(@CurrentUser() user: AuthUser) {
|
||||
return this.categories.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
createCategory(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: CreateNoteCategoryDto,
|
||||
) {
|
||||
return this.categories.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('categories/reorder')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
reorderCategories(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: ReorderNoteCategoriesDto,
|
||||
) {
|
||||
return this.categories.reorder(user.organizationId, dto.ids);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updateCategory(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateNoteCategoryDto,
|
||||
) {
|
||||
return this.categories.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
async removeCategory(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
await this.categories.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { MemoryModule } from '../memory/memory.module';
|
||||
import { NotesController } from './notes.controller';
|
||||
import { NoteAiService } from './note-ai.service';
|
||||
import { NoteCategoriesService } from './note-categories.service';
|
||||
import { NotesService } from './notes.service';
|
||||
|
||||
@Module({
|
||||
imports: [MemoryModule],
|
||||
controllers: [NotesController],
|
||||
providers: [NotesService, NoteAiService],
|
||||
providers: [NotesService, NoteAiService, NoteCategoriesService],
|
||||
exports: [NotesService],
|
||||
})
|
||||
export class NotesModule {}
|
||||
|
||||
@@ -19,7 +19,9 @@ import * as path from 'path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
||||
import {
|
||||
type Actor,
|
||||
noteAccessWhere,
|
||||
@@ -45,6 +47,7 @@ type NoteRow = {
|
||||
checklist: Prisma.JsonValue | null;
|
||||
locationText: string | null;
|
||||
analyzeEnabled: boolean;
|
||||
customFields: Prisma.JsonValue;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
@@ -112,6 +115,7 @@ export class NotesService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly game: GamificationService,
|
||||
private readonly storage: StorageQuotaService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -224,6 +228,7 @@ export class NotesService {
|
||||
: Prisma.JsonNull,
|
||||
locationText: emptyToNull(dto.locationText ?? null),
|
||||
analyzeEnabled,
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
@@ -280,6 +285,9 @@ export class NotesService {
|
||||
if (dto.analyzeEnabled !== undefined) {
|
||||
data.analyzeEnabled = dto.analyzeEnabled;
|
||||
}
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
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);
|
||||
@@ -438,10 +446,12 @@ export class NotesService {
|
||||
): Promise<void> {
|
||||
const note = await this.prisma.note.findFirst({
|
||||
where: { id, organizationId, ...noteAccessWhere(actor) },
|
||||
include: { attachments: { select: { storageKey: true } } },
|
||||
include: { attachments: { select: { storageKey: true, sizeBytes: true } } },
|
||||
});
|
||||
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
|
||||
|
||||
const bytes = note.attachments.reduce((sum, file) => sum + file.sizeBytes, 0);
|
||||
if (bytes) await this.storage.addLocal(organizationId, -bytes);
|
||||
for (const att of note.attachments) {
|
||||
await this.files.remove(att.storageKey);
|
||||
}
|
||||
@@ -485,6 +495,7 @@ export class NotesService {
|
||||
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
await this.storage.assertCanAdd(organizationId, file.size);
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
|
||||
try {
|
||||
@@ -500,6 +511,7 @@ export class NotesService {
|
||||
uploadedById: userId,
|
||||
},
|
||||
});
|
||||
await this.storage.addLocal(organizationId, file.size);
|
||||
return this.toAttachmentDto(row);
|
||||
} catch (err) {
|
||||
await this.files.remove(storageKey);
|
||||
@@ -520,6 +532,7 @@ export class NotesService {
|
||||
if (!row) throw new NotFoundException('پیوست پیدا نشد');
|
||||
await this.files.remove(row.storageKey);
|
||||
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
|
||||
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||
}
|
||||
|
||||
async openAttachmentDownload(
|
||||
@@ -616,6 +629,7 @@ export class NotesService {
|
||||
checklist: parseChecklist(row.checklist),
|
||||
locationText: row.locationText ?? null,
|
||||
analyzeEnabled: row.analyzeEnabled ?? true,
|
||||
customFields: asCustomFields(row.customFields),
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
@@ -36,6 +37,10 @@ export class CreateOrgRoleDto {
|
||||
@IsIn(PERMISSION_KEYS, { each: true })
|
||||
permissions?: RolePermissionKey[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
dataAccessPolicy?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
@@ -64,6 +69,10 @@ export class UpdateOrgRoleDto {
|
||||
@IsIn(PERMISSION_KEYS, { each: true })
|
||||
permissions?: RolePermissionKey[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
dataAccessPolicy?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
|
||||
@@ -5,7 +5,13 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { OrgRoleDto, RolePermissionKey, UserRole } from '@mento/shared';
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
OrgRoleDto,
|
||||
RolePermissionKey,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { parseDataAccessPolicy } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
@@ -25,6 +31,7 @@ type RoleRow = {
|
||||
description: string;
|
||||
color: string;
|
||||
permissions: string[];
|
||||
dataAccessPolicy?: unknown;
|
||||
systemKey: string | null;
|
||||
parentId: string | null;
|
||||
createdAt: Date;
|
||||
@@ -70,6 +77,7 @@ export class OrgRolesService {
|
||||
description?: string;
|
||||
color?: string;
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
@@ -101,6 +109,9 @@ export class OrgRolesService {
|
||||
color: dto.color ?? parent?.color ?? 'teal',
|
||||
permissions,
|
||||
parentId,
|
||||
...(dto.dataAccessPolicy
|
||||
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
|
||||
: {}),
|
||||
},
|
||||
include: ROLE_MEMBERS_INCLUDE,
|
||||
});
|
||||
@@ -122,6 +133,7 @@ export class OrgRolesService {
|
||||
description?: string;
|
||||
color?: string;
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
@@ -144,6 +156,9 @@ export class OrgRolesService {
|
||||
if (dto.permissions !== undefined) {
|
||||
data.permissions = sanitizePermissions(dto.permissions);
|
||||
}
|
||||
if (dto.dataAccessPolicy !== undefined) {
|
||||
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
|
||||
}
|
||||
|
||||
if (dto.parentId !== undefined) {
|
||||
const nextParentId =
|
||||
@@ -410,6 +425,7 @@ export class OrgRolesService {
|
||||
description: row.description,
|
||||
color: row.color,
|
||||
permissions: sanitizePermissions(row.permissions),
|
||||
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
|
||||
systemKey,
|
||||
parentId: row.parentId,
|
||||
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
|
||||
|
||||
@@ -41,7 +41,9 @@ export const SYSTEM_ROLE_SEEDS: Array<{
|
||||
name: 'کارشناس فروش',
|
||||
description: 'امکان دسترسی به بخشهای فروش و مشتریان برای انجام فعالیتهای روزمره.',
|
||||
color: 'teal',
|
||||
permissions: [...ALL_ROLE_PERMISSIONS],
|
||||
permissions: ALL_ROLE_PERMISSIONS.filter(
|
||||
(key) => key !== 'audit' && !key.startsWith('assign_'),
|
||||
),
|
||||
},
|
||||
{
|
||||
systemKey: 'support',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
@@ -26,8 +27,9 @@ import { DealsExcelService } from './deals-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('deals')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
|
||||
@RequireOrgModule('pipeline')
|
||||
@RequirePermission('sales')
|
||||
export class DealsController {
|
||||
constructor(
|
||||
private readonly dealsService: DealsService,
|
||||
|
||||
@@ -22,11 +22,11 @@ import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
|
||||
const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
@@ -106,6 +106,10 @@ export class DealsService {
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
|
||||
const allowOthers = await actorCanAssignToOthers(this.prisma, assignActor, 'sales');
|
||||
const ownerId = resolveOwnerId(actor, dto.ownerId, allowOthers);
|
||||
await assertAssigneeSet(this.prisma, assignActor, 'sales', [ownerId, ...assigneeIds]);
|
||||
|
||||
const maxPos = await this.prisma.deal.aggregate({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
@@ -156,7 +160,7 @@ export class DealsService {
|
||||
createdById: actor.userId,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
@@ -259,17 +263,38 @@ export class DealsService {
|
||||
}
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
}
|
||||
|
||||
let assigneeConnect: { id: string }[] | undefined;
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
if (dto.ownerId !== undefined || dto.assigneeIds !== undefined) {
|
||||
const current = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { ownerId: true, assignees: { select: { id: true } } },
|
||||
});
|
||||
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
|
||||
if (dto.ownerId !== undefined) {
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
assignActor,
|
||||
'sales',
|
||||
[nextOwner],
|
||||
[current?.ownerId],
|
||||
);
|
||||
data.ownerId = nextOwner ?? actor.userId;
|
||||
}
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
assignActor,
|
||||
'sales',
|
||||
assigneeIds,
|
||||
current?.assignees.map((item) => item.id) ?? [],
|
||||
);
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
}
|
||||
}
|
||||
|
||||
if (pricedFromLines && dto.lineItems) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -24,7 +25,8 @@ import {
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
@Controller('pipelines')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
|
||||
@RequirePermission('sales')
|
||||
export class PipelinesController {
|
||||
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
@@ -52,6 +54,31 @@ export class ResetPlatformAdminPasswordDto {
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class UpdatePlatformAdminDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
fullName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(40)
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null && value !== '')
|
||||
@IsEmail()
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export class SetMasterPasswordDto {
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(120)
|
||||
password!: string | null;
|
||||
}
|
||||
|
||||
export class UpdatePlatformStatusDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
@@ -146,6 +173,22 @@ export class ApplyBillingPlanDto {
|
||||
status?: 'active' | 'expired' | 'suspended';
|
||||
}
|
||||
|
||||
export class SetUserCapDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100_000)
|
||||
maxUsers!: number;
|
||||
}
|
||||
|
||||
export class SetStorageQuotaDto {
|
||||
/** گیگابایت؛ null = بدون سقف */
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100_000)
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
quotaGb!: number | null;
|
||||
}
|
||||
|
||||
export class UpdateOrgModulesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
ApplyBillingPlanDto,
|
||||
SetStorageQuotaDto,
|
||||
SetUserCapDto,
|
||||
CreatePlatformAdminDto,
|
||||
CreatePlatformOrganizationDto,
|
||||
ResetPlatformAdminPasswordDto,
|
||||
SetMasterPasswordDto,
|
||||
UpdatePlatformAdminDto,
|
||||
ResetPlatformUserPasswordDto,
|
||||
UpdateOrgModulesDto,
|
||||
UpdatePlatformMembershipRoleDto,
|
||||
@@ -71,6 +77,26 @@ export class PlatformController {
|
||||
return this.platform.resetAdminPassword(id, dto);
|
||||
}
|
||||
|
||||
@Patch('admins/:id/profile')
|
||||
updateAdmin(@Param('id') id: string, @Body() dto: UpdatePlatformAdminDto) {
|
||||
return this.platform.updateAdmin(id, dto);
|
||||
}
|
||||
|
||||
@Delete('admins/:id')
|
||||
deleteAdmin(@Param('id') id: string, @CurrentUser() actor: PlatformActor) {
|
||||
return this.platform.deleteAdmin(id, actor.platformAdminId);
|
||||
}
|
||||
|
||||
@Get('master-password')
|
||||
masterPasswordStatus() {
|
||||
return this.platform.masterPasswordStatus();
|
||||
}
|
||||
|
||||
@Put('master-password')
|
||||
setMasterPassword(@Body() dto: SetMasterPasswordDto) {
|
||||
return this.platform.setMasterPassword(dto.password);
|
||||
}
|
||||
|
||||
@Get('organizations')
|
||||
listOrganizations(@Query('q') q?: string) {
|
||||
return this.platform.listOrganizations(q);
|
||||
@@ -122,6 +148,26 @@ export class PlatformController {
|
||||
return this.platform.applyBillingPlan(id, dto);
|
||||
}
|
||||
|
||||
@Get('users/:id/storage')
|
||||
storageUsage(@Param('id') id: string, @Query('refresh') refresh?: string) {
|
||||
return this.platform.storageUsage(id, refresh === '1' || refresh === 'true');
|
||||
}
|
||||
|
||||
@Patch('users/:id/billing/storage')
|
||||
setStorageQuota(@Param('id') id: string, @Body() dto: SetStorageQuotaDto) {
|
||||
return this.platform.setStorageQuota(id, dto.quotaGb);
|
||||
}
|
||||
|
||||
@Get('users/:id/seats')
|
||||
seatUsage(@Param('id') id: string) {
|
||||
return this.platform.seatUsage(id);
|
||||
}
|
||||
|
||||
@Patch('users/:id/billing/users')
|
||||
setUserCap(@Param('id') id: string, @Body() dto: SetUserCapDto) {
|
||||
return this.platform.setUserCap(id, dto.maxUsers);
|
||||
}
|
||||
|
||||
@Patch('organizations/:id/modules')
|
||||
updateOrganizationModules(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -19,6 +19,8 @@ import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||
import { BillingService, toSummary } from '../billing/billing.service';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import type {
|
||||
@@ -50,6 +52,8 @@ export class PlatformService {
|
||||
private readonly orgRoles: OrgRolesService,
|
||||
private readonly pipelines: PipelinesService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly storage: StorageQuotaService,
|
||||
private readonly adminAlerts: PlatformAdminAlertService,
|
||||
) {}
|
||||
|
||||
async stats(): Promise<PlatformStatsDto> {
|
||||
@@ -107,15 +111,23 @@ export class PlatformService {
|
||||
where: { role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 1,
|
||||
select: { user: { select: { billingAccount: true } } },
|
||||
select: {
|
||||
userId: true,
|
||||
user: { select: { fullName: true, billingAccount: true } },
|
||||
},
|
||||
},
|
||||
conversationSettings: { select: { organizationId: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((row) => {
|
||||
const account = row.memberships[0]?.user.billingAccount;
|
||||
const owner = row.memberships[0];
|
||||
const account = owner?.user.billingAccount;
|
||||
return {
|
||||
...this.orgToDto(row),
|
||||
subscription: account ? toSummary(account) : null,
|
||||
ownerUserId: owner?.userId ?? null,
|
||||
ownerName: owner?.user.fullName ?? null,
|
||||
chatConfigured: Boolean(row.conversationSettings),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -275,6 +287,12 @@ export class PlatformService {
|
||||
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||
await this.adminAlerts.notifyNewAccount({
|
||||
source: 'admin',
|
||||
organizationName: organization.name,
|
||||
ownerName: owner.fullName,
|
||||
mobile: owner.mobile,
|
||||
});
|
||||
|
||||
return this.getOrganization(organization.id);
|
||||
} catch (e) {
|
||||
@@ -400,6 +418,50 @@ export class PlatformService {
|
||||
return this.userToDto(row);
|
||||
}
|
||||
|
||||
async storageUsage(userId: string, refresh = false) {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { userId, role: 'owner', isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owner) {
|
||||
throw new BadRequestException('حجم فایل فقط برای مالک محیط معنی دارد');
|
||||
}
|
||||
const account = await this.billing.getByOwnerUserId(userId);
|
||||
if (!account) {
|
||||
throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید');
|
||||
}
|
||||
return this.storage.usageForOwner(userId, refresh);
|
||||
}
|
||||
|
||||
async seatUsage(userId: string) {
|
||||
await this.requireBillingOwner(userId);
|
||||
return this.billing.seatUsage(userId);
|
||||
}
|
||||
|
||||
async setUserCap(userId: string, maxUsers: number) {
|
||||
await this.requireBillingOwner(userId);
|
||||
return this.billing.setMaxUsers(userId, maxUsers);
|
||||
}
|
||||
|
||||
private async requireBillingOwner(userId: string) {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { userId, role: 'owner', isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owner) {
|
||||
throw new BadRequestException('سقف کاربر فقط برای مالک محیط معنی دارد');
|
||||
}
|
||||
const account = await this.billing.getByOwnerUserId(userId);
|
||||
if (!account) {
|
||||
throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید');
|
||||
}
|
||||
}
|
||||
|
||||
async setStorageQuota(userId: string, quotaGb: number | null) {
|
||||
await this.storageUsage(userId, false);
|
||||
return this.storage.setQuotaGb(userId, quotaGb);
|
||||
}
|
||||
|
||||
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -465,9 +527,13 @@ export class PlatformService {
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
if (isActive && !row.isActive) {
|
||||
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
|
||||
}
|
||||
if (!isActive && row.role === 'owner') {
|
||||
const activeOwners = await this.memberships.countActiveOwners(
|
||||
row.organizationId,
|
||||
@@ -640,6 +706,104 @@ export class PlatformService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async updateAdmin(
|
||||
id: string,
|
||||
dto: { fullName: string; mobile: string; email?: string | null },
|
||||
): Promise<PlatformAdminDto> {
|
||||
const existing = await this.prisma.platformAdmin.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
|
||||
const fullName = dto.fullName.trim();
|
||||
if (fullName.length < 2) {
|
||||
throw new BadRequestException('نام معتبر نیست');
|
||||
}
|
||||
const mobile = normalizeMobile(dto.mobile);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
const email = dto.email?.trim().toLowerCase() || null;
|
||||
const mobileTaken = await this.prisma.platformAdmin.findFirst({
|
||||
where: { mobile, id: { not: id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (mobileTaken) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
if (email) {
|
||||
const emailTaken = await this.prisma.platformAdmin.findFirst({
|
||||
where: { email, id: { not: id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (emailTaken) {
|
||||
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
try {
|
||||
const updated = await this.prisma.platformAdmin.update({
|
||||
where: { id },
|
||||
data: { fullName, mobile, email },
|
||||
});
|
||||
return this.adminToDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('موبایل یا ایمیل تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAdmin(id: string, actorId: string): Promise<{ ok: true }> {
|
||||
const existing = await this.prisma.platformAdmin.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
|
||||
if (id === actorId) {
|
||||
throw new BadRequestException('نمیتوانید حساب خودتان را حذف کنید');
|
||||
}
|
||||
const others = await this.prisma.platformAdmin.count({
|
||||
where: { id: { not: id } },
|
||||
});
|
||||
if (others < 1) {
|
||||
throw new BadRequestException('حداقل یک سوپرادمین باید بماند');
|
||||
}
|
||||
await this.prisma.platformAdmin.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async masterPasswordStatus(): Promise<{ enabled: boolean; updatedAt: string | null }> {
|
||||
const row = await this.prisma.platformMasterPassword.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return {
|
||||
enabled: Boolean(row),
|
||||
updatedAt: row?.updatedAt.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async setMasterPassword(
|
||||
password: string | null,
|
||||
): Promise<{ enabled: boolean; updatedAt: string | null }> {
|
||||
const trimmed = password?.trim() ?? '';
|
||||
if (!trimmed) {
|
||||
await this.prisma.platformMasterPassword.deleteMany({ where: { id: 'default' } });
|
||||
return { enabled: false, updatedAt: null };
|
||||
}
|
||||
if (trimmed.length < 8) {
|
||||
throw new BadRequestException('رمز کلی حداقل ۸ کاراکتر لازم است');
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(trimmed, 10);
|
||||
const row = await this.prisma.platformMasterPassword.upsert({
|
||||
where: { id: 'default' },
|
||||
create: { id: 'default', passwordHash },
|
||||
update: { passwordHash },
|
||||
});
|
||||
return { enabled: true, updatedAt: row.updatedAt.toISOString() };
|
||||
}
|
||||
|
||||
private adminToDto(row: {
|
||||
id: string;
|
||||
mobile: string | null;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -27,7 +28,8 @@ import { ProductsExcelService } from './products-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('products')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
|
||||
@RequirePermission('products')
|
||||
export class ProductsController {
|
||||
constructor(
|
||||
private readonly productsService: ProductsService,
|
||||
|
||||
@@ -15,12 +15,12 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
projectCardAccessWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجامشده'] as const;
|
||||
@@ -35,7 +35,13 @@ const cardInclude = {
|
||||
|
||||
type CardActor = Pick<
|
||||
Actor,
|
||||
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
|
||||
| 'userId'
|
||||
| 'role'
|
||||
| 'dataVisibility'
|
||||
| 'visibleUserIds'
|
||||
| 'scopes'
|
||||
| 'projectsAccess'
|
||||
| 'teamPeerIds'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -200,6 +206,13 @@ export class ProjectBoardsService {
|
||||
});
|
||||
|
||||
const assigneeIds = [...new Set((dto.assigneeIds ?? []).map((id) => id.trim()).filter(Boolean))];
|
||||
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
|
||||
const ownerId = resolveOwnerId(
|
||||
actor,
|
||||
dto.ownerId,
|
||||
await actorCanAssignToOthers(this.prisma, assignActor, 'projects'),
|
||||
);
|
||||
await assertAssigneeSet(this.prisma, assignActor, 'projects', [ownerId, ...assigneeIds]);
|
||||
const created = await this.prisma.projectCard.create({
|
||||
data: {
|
||||
organizationId,
|
||||
@@ -211,7 +224,7 @@ export class ProjectBoardsService {
|
||||
personId: emptyToNull(dto.personId),
|
||||
companyId: emptyToNull(dto.companyId),
|
||||
dealId: emptyToNull(dto.dealId),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
ownerId,
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
@@ -251,24 +264,40 @@ export class ProjectBoardsService {
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('ownerId' in dto) {
|
||||
if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) {
|
||||
throw new BadRequestException('تغییر مسئول مجاز نیست');
|
||||
}
|
||||
data.ownerId = emptyToNull(dto.ownerId ?? null);
|
||||
const nextOwner = emptyToNull(dto.ownerId ?? null);
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'projects',
|
||||
[nextOwner],
|
||||
[existing.ownerId],
|
||||
);
|
||||
data.ownerId = nextOwner;
|
||||
}
|
||||
|
||||
const nextAssigneeIds = dto.assigneeIds
|
||||
? [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))]
|
||||
: undefined;
|
||||
if (nextAssigneeIds) {
|
||||
const currentAssignees = await this.prisma.projectCard.findFirst({
|
||||
where: { id },
|
||||
select: { assignees: { select: { id: true } } },
|
||||
});
|
||||
await assertAssigneeSet(
|
||||
this.prisma,
|
||||
{ userId: actor.userId, role: actor.role, organizationId },
|
||||
'projects',
|
||||
nextAssigneeIds,
|
||||
currentAssignees?.assignees.map((item) => item.id) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
const row = await this.prisma.projectCard.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(dto.assigneeIds
|
||||
? {
|
||||
assignees: {
|
||||
set: [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))].map(
|
||||
(uid) => ({ id: uid }),
|
||||
),
|
||||
},
|
||||
}
|
||||
...(nextAssigneeIds
|
||||
? { assignees: { set: nextAssigneeIds.map((uid) => ({ id: uid })) } }
|
||||
: {}),
|
||||
},
|
||||
include: cardInclude,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
CreateQuoteDto,
|
||||
@@ -21,7 +22,8 @@ import { QuotesService } from './quotes.service';
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('quotes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('billing')
|
||||
export class QuotesController {
|
||||
constructor(private readonly quotesService: QuotesService) {}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { SavedViewsService } from '../saved-views/saved-views.service';
|
||||
@@ -52,7 +53,8 @@ class UpdateReportViewDto {
|
||||
}
|
||||
|
||||
@Controller('reports')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
|
||||
@RequirePermission('reports', { reads: true })
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly reportsService: ReportsService,
|
||||
@@ -95,7 +97,9 @@ export class ReportsController {
|
||||
});
|
||||
}
|
||||
|
||||
/** داشبورد صفحهٔ خانه برای همه باز است */
|
||||
@Get('home')
|
||||
@RequirePermission('reports', { reads: false })
|
||||
home(@CurrentUser() user: Actor) {
|
||||
return this.reportsService.home(user);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Prisma, type RoutineTaskSource } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* کارهای روتین فقط کار داخلی تیم است.
|
||||
* پیگیری، لید، مشتری و معامله روی پرونده خودشان میمانند و وارد میز روتین نمیشوند.
|
||||
*/
|
||||
export const internalRoutineTaskWhere: Prisma.RoutineTaskWhereInput = {
|
||||
source: { in: ['routine', 'manual'] },
|
||||
leadId: null,
|
||||
personId: null,
|
||||
companyId: null,
|
||||
dealId: null,
|
||||
invoiceId: null,
|
||||
activityId: null,
|
||||
};
|
||||
|
||||
/** فیلتر میز روتین. منبع مشتری حتی اگر صریحاً خواسته شود وارد لیست نمیشود. */
|
||||
export function deskRoutineTaskWhere(
|
||||
source?: RoutineTaskSource | null,
|
||||
): Prisma.RoutineTaskWhereInput {
|
||||
if (source === 'routine' || source === 'manual') {
|
||||
return { ...internalRoutineTaskWhere, source };
|
||||
}
|
||||
if (source) return { AND: [internalRoutineTaskWhere, { source }] };
|
||||
return internalRoutineTaskWhere;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user