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:
2026-11-03 16:55:42 +03:30
201 changed files with 19202 additions and 1607 deletions
+1 -1
View File
@@ -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;
+220
View File
@@ -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);
+2 -1
View File
@@ -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,
+469 -58
View File
@@ -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));
}
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
+37
View File
@@ -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()
+4
View File
@@ -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) {
+2
View File
@@ -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,
+24 -3
View File
@@ -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,
+95 -12
View File
@@ -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 } : {}),
+36
View File
@@ -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;
}
}
+19 -2
View File
@@ -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 {}
+80 -1
View File
@@ -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' };
}
}
+62
View File
@@ -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));
}
+470
View File
@@ -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);
}
+62 -12
View File
@@ -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);
}
+13
View File
@@ -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,
+82 -10
View File
@@ -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 },
});
+2
View File
@@ -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],
+45 -5
View File
@@ -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[];
}
+29 -19
View File
@@ -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;
}
+10 -1
View File
@@ -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,
+134 -24
View File
@@ -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 {
+12
View File
@@ -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)
+1
View File
@@ -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
+94
View File
@@ -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) {}
+3 -1
View File
@@ -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) {}
+2
View File
@@ -32,6 +32,8 @@ const MAP_TO = [
'lead.trade',
'lead.notes',
'lead.source',
'lead.gender',
'person.gender',
'notes',
'custom',
] as const;
+35 -3
View File
@@ -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');
}
+5
View File
@@ -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;
+42 -17
View File
@@ -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;
+4 -3
View File
@@ -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)
+143 -43
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
}
}
+60 -2
View File
@@ -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 -1
View File
@@ -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 {}
+15 -1
View File
@@ -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,
+9
View File
@@ -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()
+17 -1
View File
@@ -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,
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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,
+37 -12
View File
@@ -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) {}
+43
View File
@@ -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,
+166 -2
View File
@@ -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;
+3 -1
View File
@@ -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,
+3 -1
View File
@@ -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) {}
+5 -1
View File
@@ -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);
}
+26
View File
@@ -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