feat: add DISC profiles and a sales copilot as an org module

Help reps capture a customer's likely behavior style and get negotiation guidance, with the feature gated from super admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 21:04:26 +03:30
co-authored by Cursor
parent 96befd3867
commit 002f697dfe
27 changed files with 2838 additions and 26 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": "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"
},
"dependencies": {
"@mento/shared": "*",
@@ -0,0 +1,97 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "discLabel" TEXT,
ADD COLUMN "discReliable" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "discAssessedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "Person_organizationId_discLabel_idx" ON "Person"("organizationId", "discLabel");
-- CreateTable
CREATE TABLE "DiscAssessment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"assessorId" TEXT NOT NULL,
"answers" JSONB NOT NULL,
"scoreD" INTEGER NOT NULL,
"scoreI" INTEGER NOT NULL,
"scoreS" INTEGER NOT NULL,
"scoreC" INTEGER NOT NULL,
"percentD" INTEGER NOT NULL,
"percentI" INTEGER NOT NULL,
"percentS" INTEGER NOT NULL,
"percentC" INTEGER NOT NULL,
"validCount" INTEGER NOT NULL,
"primaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"secondaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"label" TEXT NOT NULL,
"secondaryLabel" TEXT,
"reliable" BOOLEAN NOT NULL,
"confidence" TEXT NOT NULL,
"note" TEXT,
"status" TEXT NOT NULL DEFAULT 'confirmed',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DiscAssessment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalesCopilotSession" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT,
"leadId" TEXT,
"dealId" TEXT,
"userId" TEXT NOT NULL,
"topic" TEXT NOT NULL,
"goal" TEXT NOT NULL,
"customerStatement" TEXT NOT NULL,
"behaviorNotes" TEXT,
"analysis" JSONB NOT NULL,
"confirmedResponse" TEXT,
"nextAction" TEXT,
"followUpAt" TIMESTAMP(3),
"callOutcome" TEXT,
"agentNote" TEXT,
"status" TEXT NOT NULL,
"messageDraft" TEXT,
"activityId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesCopilotSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DiscAssessment_organizationId_personId_createdAt_idx" ON "DiscAssessment"("organizationId", "personId", "createdAt");
CREATE INDEX "DiscAssessment_personId_idx" ON "DiscAssessment"("personId");
CREATE INDEX "SalesCopilotSession_organizationId_personId_createdAt_idx" ON "SalesCopilotSession"("organizationId", "personId", "createdAt");
CREATE INDEX "SalesCopilotSession_organizationId_leadId_idx" ON "SalesCopilotSession"("organizationId", "leadId");
CREATE INDEX "SalesCopilotSession_dealId_idx" ON "SalesCopilotSession"("dealId");
-- AddForeignKey
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_assessorId_fkey" FOREIGN KEY ("assessorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ماژول برای محیط‌هایی که از قبل ماژول دارند روشن می‌شود و در سوپرادمین قابل خاموش کردن است
UPDATE "Organization"
SET "enabledModules" = array_append("enabledModules", 'behavior')
WHERE cardinality("enabledModules") > 0
AND NOT ('behavior' = ANY("enabledModules"));
-- مجوز رفتار برای نقش‌هایی که فروش دارند؛ پشتیبانی بدون این کلید می‌ماند
UPDATE "OrgRole"
SET "permissions" = array_append("permissions", 'behavior')
WHERE NOT ('behavior' = ANY("permissions"))
AND (
"systemKey" IN ('owner', 'admin', 'sales')
OR 'sales' = ANY("permissions")
);
+80
View File
@@ -161,6 +161,8 @@ model Organization {
teams Team[]
companies Company[]
people Person[]
discAssessments DiscAssessment[]
salesCopilotSessions SalesCopilotSession[]
pipelines Pipeline[]
deals Deal[]
activities Activity[]
@@ -311,6 +313,8 @@ model User {
routineTaskEvents RoutineTaskEvent[]
routineSettings RoutineUserSettings[]
deletionArchives DeletionArchive[] @relation("DeletionActor")
discAssessments DiscAssessment[] @relation("DiscAssessor")
salesCopilotSessions SalesCopilotSession[] @relation("CopilotAuthor")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -554,6 +558,11 @@ model Person {
routineTasks RoutineTask[]
outboundSmsLogs OutboundSmsLog[]
financialDocuments FinancialDocument[]
discLabel String?
discReliable Boolean @default(false)
discAssessedAt DateTime?
discAssessments DiscAssessment[]
salesCopilotSessions SalesCopilotSession[]
financialReceivables FinancialReceivable[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -569,6 +578,7 @@ model Person {
@@index([companyId])
@@index([ownerId])
@@index([loyaltyTierId])
@@index([organizationId, discLabel])
}
model Pipeline {
@@ -640,6 +650,7 @@ model Deal {
expectedCloseAt DateTime?
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
salesCopilotSessions SalesCopilotSession[]
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
ownerId String?
@@ -2766,6 +2777,7 @@ model Lead {
convertedAt DateTime?
archivedAt DateTime?
events LeadEvent[]
salesCopilotSessions SalesCopilotSession[]
trackingLinks TrackingLink[]
trackingEvents TrackingEvent[]
routineTasks RoutineTask[]
@@ -3337,3 +3349,71 @@ model GamificationBonus {
@@index([organizationId, isActive])
}
/// ارزیابی رفتاری DISC؛ هر بار یک رکورد تازه است و تاریخچه پاک نمی‌شود.
model DiscAssessment {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
assessorId String
assessor User @relation("DiscAssessor", fields: [assessorId], references: [id], onDelete: Restrict)
answers Json
scoreD Int
scoreI Int
scoreS Int
scoreC Int
percentD Int
percentI Int
percentS Int
percentC Int
validCount Int
primaryTypes String[] @default([])
secondaryTypes String[] @default([])
label String
secondaryLabel String?
reliable Boolean
confidence String
note String?
status String @default("confirmed")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, personId, createdAt])
@@index([personId])
}
/// نتیجهٔ تأییدشدهٔ دستیار فروش. پیشنهاد خام بدون تأیید کارشناس اینجا ذخیره نمی‌شود.
model SalesCopilotSession {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
leadId String?
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
dealId String?
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
userId String
user User @relation("CopilotAuthor", fields: [userId], references: [id], onDelete: Restrict)
topic String
goal String
customerStatement String
behaviorNotes String?
analysis Json
confirmedResponse String?
nextAction String?
followUpAt DateTime?
callOutcome String?
agentNote String?
status String
messageDraft String?
activityId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, personId, createdAt])
@@index([organizationId, leadId])
@@index([dealId])
}
+2
View File
@@ -52,6 +52,7 @@ import { RoutinesModule } from './routines/routines.module';
import { GamificationModule } from './gamification/gamification.module';
import { DeletionsModule } from './deletions/deletions.module';
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
import { BehaviorModule } from './behavior/behavior.module';
@Module({
imports: [
@@ -111,6 +112,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
RoutinesModule,
GamificationModule,
DeletionsModule,
BehaviorModule,
],
})
export class AppModule {}
@@ -0,0 +1,39 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import type { Actor } from '../common/record-scope';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class BehaviorAccessService {
constructor(
private readonly prisma: PrismaService,
private readonly billing: BillingService,
) {}
async canView(actor: Actor): Promise<boolean> {
const org = await this.prisma.organization.findUnique({
where: { id: actor.organizationId },
select: { enabledModules: true },
});
if (!this.billing.normalizeModules(org?.enabledModules).includes('behavior')) {
return false;
}
if (actor.role === 'owner' || actor.role === 'admin') 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 ?? [];
return permissions.includes('behavior') || permissions.includes('sales');
}
async assert(actor: Actor): Promise<void> {
if (!(await this.canView(actor))) {
throw new ForbiddenException('دسترسی به تحلیل رفتاری و دستیار فروش ندارید');
}
}
}
@@ -0,0 +1,85 @@
import {
Body,
Controller,
Get,
Header,
Param,
Post,
Query,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { DiscService } from './disc.service';
import { CopilotService } from './copilot.service';
import { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto, SaveDiscDto } from './dto';
@Controller('behavior')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('behavior')
export class BehaviorController {
constructor(
private readonly disc: DiscService,
private readonly copilot: CopilotService,
) {}
@Get('disc/distribution')
distribution(@CurrentUser() user: Actor) {
return this.disc.distribution(user);
}
@Get('disc/export')
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename="disc.xlsx"')
async exportDisc(@CurrentUser() user: Actor) {
const buf = await this.disc.exportXlsx(user);
return new StreamableFile(buf);
}
@Get('people/:personId/disc')
listDisc(@CurrentUser() user: Actor, @Param('personId') personId: string) {
return this.disc.list(user, personId);
}
@Post('people/:personId/disc')
saveDisc(
@CurrentUser() user: Actor,
@Param('personId') personId: string,
@Body() dto: SaveDiscDto,
) {
return this.disc.save(user, personId, dto);
}
@Post('copilot/analyze')
analyze(@CurrentUser() user: Actor, @Body() dto: AnalyzeCopilotDto) {
return this.copilot.analyze(user, dto);
}
@Get('copilot/sessions')
sessions(
@CurrentUser() user: Actor,
@Query('personId') personId?: string,
@Query('leadId') leadId?: string,
@Query('dealId') dealId?: string,
) {
return this.copilot.list(user, { personId, leadId, dealId });
}
@Post('copilot/sessions')
saveSession(@CurrentUser() user: Actor, @Body() dto: SaveCopilotDto) {
return this.copilot.save(user, dto);
}
@Post('copilot/sessions/:id/follow-up')
followUp(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: CopilotFollowUpDto,
) {
return this.copilot.createFollowUp(user, id, dto);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ActivitiesModule } from '../activities/activities.module';
import { BehaviorAccessService } from './behavior-access.service';
import { BehaviorController } from './behavior.controller';
import { CopilotService } from './copilot.service';
import { DiscService } from './disc.service';
@Module({
imports: [ActivitiesModule],
controllers: [BehaviorController],
providers: [BehaviorAccessService, DiscService, CopilotService],
exports: [BehaviorAccessService],
})
export class BehaviorModule {}
+345
View File
@@ -0,0 +1,345 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
analyzeCopilot,
isDiscType,
type CopilotAnalysis,
type CopilotFacts,
type SalesCopilotSessionDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { dealAccessWhere, ownerWhere, scopedOwnerIds, seesAllRecords } from '../common/record-scope';
import { ActivitiesService } from '../activities/activities.service';
import { AiService } from '../ai/ai.service';
import { BehaviorAccessService } from './behavior-access.service';
import type { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto } from './dto';
@Injectable()
export class CopilotService {
constructor(
private readonly prisma: PrismaService,
private readonly access: BehaviorAccessService,
private readonly activities: ActivitiesService,
private readonly ai: AiService,
) {}
async analyze(actor: Actor, dto: AnalyzeCopilotDto): Promise<CopilotAnalysis> {
await this.access.assert(actor);
const facts = await this.facts(actor, dto);
const analysis = analyzeCopilot({
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement,
behaviorNotes: dto.behaviorNotes,
facts,
});
return this.polish(analysis, facts, dto.customerStatement);
}
async list(
actor: Actor,
filter: { personId?: string; leadId?: string; dealId?: string },
): Promise<SalesCopilotSessionDto[]> {
await this.access.assert(actor);
if (!filter.personId && !filter.leadId && !filter.dealId) {
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
}
const rows = await this.prisma.salesCopilotSession.findMany({
where: {
organizationId: actor.organizationId,
...(filter.personId ? { personId: filter.personId } : {}),
...(filter.leadId ? { leadId: filter.leadId } : {}),
...(filter.dealId ? { dealId: filter.dealId } : {}),
},
include: { user: { select: { fullName: true } } },
orderBy: { createdAt: 'desc' },
take: 30,
});
return rows.map((row) => this.toDto(row));
}
async save(actor: Actor, dto: SaveCopilotDto): Promise<SalesCopilotSessionDto> {
await this.access.assert(actor);
const facts = await this.facts(actor, dto);
const analysis = await this.polish(
analyzeCopilot({
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement,
behaviorNotes: dto.behaviorNotes,
facts,
}),
facts,
dto.customerStatement,
);
const row = await this.prisma.salesCopilotSession.create({
data: {
organizationId: actor.organizationId,
personId: facts.personId,
leadId: facts.leadId,
dealId: facts.dealId,
userId: actor.userId,
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement.trim(),
behaviorNotes: dto.behaviorNotes?.trim() || null,
analysis: analysis as unknown as Prisma.InputJsonValue,
confirmedResponse: dto.confirmedResponse?.trim() || null,
nextAction: dto.nextAction?.trim() || analysis.next_best_action,
followUpAt: dto.followUpAt ? new Date(dto.followUpAt) : null,
callOutcome: dto.callOutcome?.trim() || null,
agentNote: dto.agentNote?.trim() || null,
status: dto.status,
messageDraft: dto.messageDraft?.trim() || null,
},
include: { user: { select: { fullName: true } } },
});
return this.toDto(row);
}
async createFollowUp(actor: Actor, sessionId: string, dto: CopilotFollowUpDto) {
await this.access.assert(actor);
const session = await this.prisma.salesCopilotSession.findFirst({
where: { id: sessionId, organizationId: actor.organizationId, status: 'confirmed' },
});
if (!session) throw new NotFoundException('نتیجهٔ تأییدشده پیدا نشد');
if (!session.personId && !session.dealId && !session.leadId) {
throw new BadRequestException('این نتیجه به پرونده‌ای وصل نیست');
}
const due = dto.dueAt || (session.followUpAt ? session.followUpAt.toISOString() : null);
const activity = await this.activities.create(
actor.organizationId,
actor.userId,
{
title: session.nextAction?.trim() || 'پیگیری مذاکره',
type: 'task',
description: session.confirmedResponse || session.customerStatement,
dueAt: due,
personId: session.personId,
dealId: session.dealId,
assigneeId: dto.assigneeId || actor.userId,
},
actor,
);
await this.prisma.salesCopilotSession.update({
where: { id: session.id },
data: { activityId: activity.id },
});
return { activityId: activity.id };
}
private async facts(
actor: Actor,
dto: { personId?: string; leadId?: string; dealId?: string },
): Promise<CopilotFacts & { personId: string | null; leadId: string | null; dealId: string | null }> {
if (!dto.personId && !dto.leadId && !dto.dealId) {
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
}
const deal = dto.dealId
? await this.prisma.deal.findFirst({
where: { id: dto.dealId, organizationId: actor.organizationId, ...dealAccessWhere(actor) },
select: {
id: true,
title: true,
amount: true,
currency: true,
personId: true,
stage: { select: { name: true } },
},
})
: null;
if (dto.dealId && !deal) throw new NotFoundException('معامله پیدا نشد');
const lead = dto.leadId
? await this.prisma.lead.findFirst({
where: { id: dto.leadId, organizationId: actor.organizationId },
select: {
id: true,
title: true,
trade: true,
estimatedValue: true,
convertedPersonId: true,
assigneeId: true,
stage: { select: { name: true } },
},
})
: null;
if (dto.leadId && !lead) throw new NotFoundException('سرنخ پیدا نشد');
if (lead && !seesAllRecords(actor, 'leads')) {
const ids = scopedOwnerIds(actor, 'leads') ?? [actor.userId];
if (lead.assigneeId && !ids.includes(lead.assigneeId)) {
throw new NotFoundException('سرنخ پیدا نشد');
}
}
const personId = dto.personId || deal?.personId || lead?.convertedPersonId || null;
const person = personId
? await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: {
id: true,
firstName: true,
lastName: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
company: { select: { name: true } },
},
})
: null;
if (personId && !person) throw new NotFoundException('شخص پیدا نشد');
const recent = person
? await this.prisma.activity.findMany({
where: { organizationId: actor.organizationId, personId: person.id },
orderBy: { createdAt: 'desc' },
take: 3,
select: { title: true },
})
: [];
const price = deal?.amount
? `${deal.amount.toString()} ${deal.currency}`
: lead?.estimatedValue
? `${lead.estimatedValue.toString()}`
: null;
const primaryTypes = (person?.discLabel ?? '')
.split('/')
.map((part) => part.trim())
.filter(isDiscType);
return {
personId: person?.id ?? null,
leadId: lead?.id ?? null,
dealId: deal?.id ?? null,
customerName: person ? `${person.firstName} ${person.lastName}`.trim() : lead?.title ?? null,
businessType: person?.company?.name || lead?.trade || null,
productName: deal?.title || lead?.title || null,
stageName: deal?.stage.name || lead?.stage.name || null,
priceOffered: price,
lastAction: recent.map((row) => row.title).join('؛ ') || null,
disc:
person && person.discLabel
? {
label: person.discLabel,
primaryTypes,
reliable: person.discReliable,
assessedAt: person.discAssessedAt?.toISOString() ?? null,
}
: null,
};
}
private async polish(
analysis: CopilotAnalysis,
facts: CopilotFacts,
statement: string,
): Promise<CopilotAnalysis> {
if (!this.ai.isEnabled() || analysis.needs_more_input || !analysis.suggested_response) {
return analysis;
}
const factText = [
statement,
facts.customerName,
facts.businessType,
facts.productName,
facts.stageName,
facts.priceOffered,
facts.lastAction,
analysis.objection_label,
]
.filter(Boolean)
.join('\n');
try {
const raw = await this.ai.completeJson<{
recommended_question?: string;
suggested_response?: string;
}>(
`تو مربی فروش فارسی‌زبان هستی. فقط JSON برگردان:
{"recommended_question": string, "suggested_response": string}
قواعد:
- چیزی را که در اطلاعات داده نشده نساز؛ به‌خصوص قیمت، تخفیف، قابلیت محصول و تعهد پشتیبانی.
- اگر عددی در اطلاعات نیست، عدد جدید ننویس.
- DISC را تشخیص قطعی شخصیت ندان.
- پاسخ کوتاه، محترمانه و قابل گفتن پای تلفن باشد.`,
factText,
);
const question = keepGrounded(raw.recommended_question, factText);
const response = keepGrounded(raw.suggested_response, factText);
if (!question && !response) return analysis;
return {
...analysis,
recommended_question: question || analysis.recommended_question,
suggested_response: response || analysis.suggested_response,
source: 'rules+ai',
confidence_note: `${analysis.confidence_note} متن با مدل زبانی بازنویسی شد و هنوز باید توسط کارشناس تأیید شود.`,
};
} catch {
return analysis;
}
}
private toDto(row: {
id: string;
personId: string | null;
leadId: string | null;
dealId: string | null;
userId: string;
user: { fullName: string };
topic: string;
goal: string;
customerStatement: string;
behaviorNotes: string | null;
analysis: unknown;
confirmedResponse: string | null;
nextAction: string | null;
followUpAt: Date | null;
callOutcome: string | null;
agentNote: string | null;
status: string;
messageDraft: string | null;
activityId: string | null;
createdAt: Date;
updatedAt: Date;
}): SalesCopilotSessionDto {
return {
id: row.id,
personId: row.personId,
leadId: row.leadId,
dealId: row.dealId,
userId: row.userId,
userName: row.user.fullName,
topic: row.topic as SalesCopilotSessionDto['topic'],
goal: row.goal as SalesCopilotSessionDto['goal'],
customerStatement: row.customerStatement,
behaviorNotes: row.behaviorNotes,
analysis: row.analysis as CopilotAnalysis,
confirmedResponse: row.confirmedResponse,
nextAction: row.nextAction,
followUpAt: row.followUpAt?.toISOString() ?? null,
callOutcome: row.callOutcome,
agentNote: row.agentNote,
status: row.status === 'dismissed' ? 'dismissed' : 'confirmed',
messageDraft: row.messageDraft,
activityId: row.activityId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function keepGrounded(text: string | undefined, facts: string): string {
const trimmed = text?.trim() ?? '';
if (!trimmed || trimmed.length > 900) return '';
const numbers = trimmed.match(/\d[\d۰-۹,،.]*/g) ?? [];
for (const token of numbers) {
const digits = token.replace(/\D/g, '');
if (digits.length >= 2 && !facts.includes(digits)) return '';
}
return trimmed;
}
+242
View File
@@ -0,0 +1,242 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
type DiscAnswers,
type DiscAssessmentDto,
type DiscDistributionRow,
type DiscType,
isDiscType,
scoreDisc,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import { writeXlsx } from '../common/excel';
import { BehaviorAccessService } from './behavior-access.service';
import type { SaveDiscDto } from './dto';
function asAnswers(value: unknown): DiscAnswers {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: DiscAnswers = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (raw == null || raw === '') out[key] = null;
else if (isDiscType(raw)) out[key] = raw;
}
return out;
}
function asTypes(values: string[]): DiscType[] {
return values.filter(isDiscType);
}
@Injectable()
export class DiscService {
constructor(
private readonly prisma: PrismaService,
private readonly access: BehaviorAccessService,
) {}
async list(actor: Actor, personId: string): Promise<DiscAssessmentDto[]> {
await this.access.assert(actor);
await this.ensurePerson(actor, personId);
const rows = await this.prisma.discAssessment.findMany({
where: { organizationId: actor.organizationId, personId },
include: { assessor: { select: { id: true, fullName: true } } },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async save(actor: Actor, personId: string, dto: SaveDiscDto): Promise<DiscAssessmentDto> {
await this.access.assert(actor);
await this.ensurePerson(actor, personId);
const answers = asAnswers(dto.answers);
const scored = scoreDisc(answers);
if (scored.validCount === 0) {
throw new BadRequestException('حداقل یک پاسخ معتبر لازم است');
}
const note = dto.note?.trim() || null;
const row = await this.prisma.discAssessment.create({
data: {
organizationId: actor.organizationId,
personId,
assessorId: actor.userId,
answers: answers as Prisma.InputJsonValue,
scoreD: scored.scores.D,
scoreI: scored.scores.I,
scoreS: scored.scores.S,
scoreC: scored.scores.C,
percentD: scored.percents.D,
percentI: scored.percents.I,
percentS: scored.percents.S,
percentC: scored.percents.C,
validCount: scored.validCount,
primaryTypes: scored.primaryTypes,
secondaryTypes: scored.secondaryTypes,
label: scored.label,
secondaryLabel: scored.secondaryLabel,
reliable: scored.reliable,
confidence: scored.confidence,
note,
status: 'confirmed',
},
include: { assessor: { select: { id: true, fullName: true } } },
});
if (scored.reliable) {
await this.prisma.person.update({
where: { id: personId },
data: {
discLabel: scored.label,
discReliable: true,
discAssessedAt: row.createdAt,
},
});
}
return this.toDto(row);
}
async distribution(actor: Actor): Promise<DiscDistributionRow[]> {
await this.access.assert(actor);
const rows = await this.prisma.person.groupBy({
by: ['discLabel'],
where: {
organizationId: actor.organizationId,
discReliable: true,
discLabel: { not: null },
...ownerWhere(actor),
},
_count: { _all: true },
});
return rows
.flatMap((row) =>
row.discLabel
? [{ label: row.discLabel, count: row._count._all }]
: [],
)
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
async exportXlsx(actor: Actor): Promise<Buffer> {
await this.access.assert(actor);
const people = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
discReliable: true,
...ownerWhere(actor),
},
select: { id: true, firstName: true, lastName: true, mobile: true, discLabel: true },
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
});
const latest = await this.prisma.discAssessment.findMany({
where: {
organizationId: actor.organizationId,
personId: { in: people.map((p) => p.id) },
reliable: true,
},
include: { assessor: { select: { fullName: true } } },
orderBy: { createdAt: 'desc' },
});
const byPerson = new Map<string, (typeof latest)[number]>();
for (const row of latest) {
if (!byPerson.has(row.personId)) byPerson.set(row.personId, row);
}
return writeXlsx(
'DISC',
[
{ header: 'مشتری', key: 'name', width: 28 },
{ header: 'موبایل', key: 'mobile', width: 16 },
{ header: 'تیپ غالب', key: 'label', width: 16 },
{ header: 'تیپ دوم', key: 'secondary', width: 16 },
{ header: 'D', key: 'd', width: 8 },
{ header: 'I', key: 'i', width: 8 },
{ header: 'S', key: 's', width: 8 },
{ header: 'C', key: 'c', width: 8 },
{ header: 'اطمینان', key: 'confidence', width: 14 },
{ header: 'کارشناس', key: 'assessor', width: 22 },
{ header: 'تاریخ', key: 'at', width: 22 },
{ header: 'یادداشت', key: 'note', width: 40 },
],
people.map((person) => {
const row = byPerson.get(person.id);
return {
name: `${person.firstName} ${person.lastName}`.trim(),
mobile: person.mobile,
label: person.discLabel,
secondary: row?.secondaryLabel,
d: row?.scoreD,
i: row?.scoreI,
s: row?.scoreS,
c: row?.scoreC,
confidence: row?.confidence,
assessor: row?.assessor.fullName,
at: row?.createdAt.toISOString(),
note: row?.note,
};
}),
);
}
private async ensurePerson(actor: Actor, personId: string) {
const found = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('شخص پیدا نشد');
}
private toDto(row: {
id: string;
personId: string;
assessorId: string;
assessor: { fullName: string };
answers: unknown;
scoreD: number;
scoreI: number;
scoreS: number;
scoreC: number;
percentD: number;
percentI: number;
percentS: number;
percentC: number;
validCount: number;
primaryTypes: string[];
secondaryTypes: string[];
label: string;
secondaryLabel: string | null;
reliable: boolean;
confidence: string;
note: string | null;
createdAt: Date;
updatedAt: Date;
}): DiscAssessmentDto {
const confidence = (['insufficient', 'low', 'medium', 'high'] as const).includes(
row.confidence as 'insufficient',
)
? (row.confidence as DiscAssessmentDto['confidence'])
: 'low';
return {
id: row.id,
personId: row.personId,
assessorId: row.assessorId,
assessorName: row.assessor.fullName,
answers: asAnswers(row.answers),
scores: { D: row.scoreD, I: row.scoreI, S: row.scoreS, C: row.scoreC },
percents: { D: row.percentD, I: row.percentI, S: row.percentS, C: row.percentC },
validCount: row.validCount,
primaryTypes: asTypes(row.primaryTypes),
secondaryTypes: asTypes(row.secondaryTypes),
label: row.label,
secondaryLabel: row.secondaryLabel,
reliable: row.reliable,
confidence,
note: row.note,
status: 'confirmed',
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+106
View File
@@ -0,0 +1,106 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
import {
COPILOT_GOALS,
COPILOT_TOPICS,
type CopilotGoal,
type CopilotTopic,
} from '@mento/shared';
export class SaveDiscDto {
@IsObject()
answers!: Record<string, string | null>;
@IsOptional()
@IsString()
@MaxLength(4000)
note?: string;
}
export class AnalyzeCopilotDto {
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
leadId?: string;
@IsOptional()
@IsString()
dealId?: string;
@IsIn([...COPILOT_TOPICS])
topic!: CopilotTopic;
@IsIn([...COPILOT_GOALS])
goal!: CopilotGoal;
@IsString()
@MaxLength(4000)
customerStatement!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
behaviorNotes?: string;
}
export class SaveCopilotDto extends AnalyzeCopilotDto {
@IsIn(['confirmed', 'dismissed'])
status!: 'confirmed' | 'dismissed';
@IsOptional()
@IsString()
@MaxLength(4000)
confirmedResponse?: string;
@IsOptional()
@IsString()
@MaxLength(500)
nextAction?: string;
@IsOptional()
@ValidateIf((_, value) => value != null && value !== '')
@IsDateString()
followUpAt?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
callOutcome?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
agentNote?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
messageDraft?: string;
}
export class CopilotFollowUpDto {
@IsOptional()
@IsDateString()
dueAt?: string;
@IsOptional()
@IsString()
assigneeId?: string;
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
createTask?: boolean;
}
+2
View File
@@ -9,6 +9,7 @@ import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { SmsService } from '../common/sms.service';
import { BehaviorModule } from '../behavior/behavior.module';
@Module({
imports: [
@@ -16,6 +17,7 @@ import { SmsService } from '../common/sms.service';
AutomationsModule,
MessageAutomationsModule,
PublicApiModule,
BehaviorModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService],
+6
View File
@@ -80,6 +80,12 @@ export class ListQueryDto {
@IsOptional()
@IsIn(['1', 'true', '0', 'false'])
hasMobile?: string;
/** فیلتر تیپ DISC غالب یا ترکیبی، مثل D یا D/C */
@IsOptional()
@IsString()
@MaxLength(12)
disc?: string;
}
class CompanyExtraFieldsDto {
@@ -59,6 +59,7 @@ export class PeopleController {
province: query.province,
city: query.city,
hasMobile,
disc: query.disc,
},
);
}
@@ -75,6 +76,7 @@ export class PeopleController {
province: query.province,
city: query.city,
hasMobile,
disc: query.disc,
});
}
+39 -7
View File
@@ -50,6 +50,7 @@ import {
normalizePersonImportSource,
personImportSourceLabel,
} from './person-source';
import { BehaviorAccessService } from '../behavior/behavior-access.service';
const personInclude = {
company: { select: { id: true, name: true, province: true, city: true } },
@@ -98,8 +99,27 @@ type PersonRow = {
minPoints: number;
color: string | null;
} | null;
discLabel: string | null;
discReliable: boolean;
discAssessedAt: Date | null;
};
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
const value = raw?.trim().toUpperCase();
if (!value || !/^[DISC](\/[DISC]){0,3}$/.test(value)) return null;
if (value.includes('/')) {
return { discReliable: true, discLabel: value };
}
return {
discReliable: true,
OR: [
{ discLabel: value },
{ discLabel: { startsWith: `${value}/` } },
{ discLabel: { contains: `/${value}` } },
],
};
}
@Injectable()
export class PeopleService implements OnModuleInit {
private readonly logger = new Logger(PeopleService.name);
@@ -113,6 +133,7 @@ export class PeopleService implements OnModuleInit {
private readonly game: GamificationService,
private readonly sms: SmsService,
private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
) {}
private personListWhere(
@@ -124,6 +145,7 @@ export class PeopleService implements OnModuleInit {
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
},
): Prisma.PersonWhereInput {
const sourceFilter = opts?.importSource?.trim()
@@ -166,6 +188,8 @@ export class PeopleService implements OnModuleInit {
if (opts?.hasMobile) {
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
}
const disc = discFilter(opts?.disc);
if (disc) and.push(disc);
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
@@ -187,9 +211,11 @@ export class PeopleService implements OnModuleInit {
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
},
): Promise<PaginatedResponse<PersonDto>> {
const where = this.personListWhere(actor, q, opts);
const seeDisc = await this.behaviorAccess.canView(actor);
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
const [rows, total] = await this.prisma.$transaction([
this.prisma.person.findMany({
@@ -206,7 +232,7 @@ export class PeopleService implements OnModuleInit {
]);
return {
items: rows.map((row) => this.toDto(row)),
items: rows.map((row) => this.toDto(row, seeDisc)),
total,
page,
limit,
@@ -222,10 +248,12 @@ export class PeopleService implements OnModuleInit {
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
},
max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> {
const where = this.personListWhere(actor, q, opts);
const seeDisc = await this.behaviorAccess.canView(actor);
const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined });
const total = await this.prisma.person.count({ where });
if (total > max) {
throw new BadRequestException(
@@ -330,7 +358,7 @@ export class PeopleService implements OnModuleInit {
include: personInclude,
});
if (!row) throw new NotFoundException('شخص پیدا نشد');
return this.toDto(row);
return this.toDto(row, await this.behaviorAccess.canView(actor));
}
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
@@ -407,7 +435,7 @@ export class PeopleService implements OnModuleInit {
where: { id: row.id },
include: personInclude,
});
return this.toDto(fresh ?? row);
return this.toDto(fresh ?? row, await this.behaviorAccess.canView(actor));
}
async update(
@@ -473,7 +501,7 @@ export class PeopleService implements OnModuleInit {
email: row.email,
companyId: row.companyId,
});
return this.toDto(row);
return this.toDto(row, await this.behaviorAccess.canView(actor));
}
async remove(actor: Actor, id: string): Promise<void> {
@@ -697,7 +725,7 @@ export class PeopleService implements OnModuleInit {
);
}
private toDto(row: PersonRow): PersonDto {
private toDto(row: PersonRow, includeDisc: boolean): PersonDto {
const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim();
const importSource = row.importSource || 'manual';
return {
@@ -736,6 +764,10 @@ export class PeopleService implements OnModuleInit {
customFields: asCustomFields(row.customFields),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
discReliable: includeDisc ? row.discReliable : false,
discAssessedAt:
includeDisc && row.discAssessedAt ? row.discAssessedAt.toISOString() : null,
company: row.company,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
+104
View File
@@ -0,0 +1,104 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
analyzeCopilot,
followUpMessage,
scoreDisc,
type DiscAnswers,
} from '../../../packages/shared/src/behavior';
function all(type: DiscAnswers[string]): DiscAnswers {
return { '1': type, '2': type, '3': type, '4': type, '5': type, '6': type, '7': type, '8': type };
}
describe('امتیاز DISC', () => {
it('گزینه D فقط امتیاز D را زیاد می‌کند', () => {
const result = scoreDisc(all('D'));
assert.equal(result.scores.D, 8);
assert.equal(result.scores.I, 0);
assert.equal(result.scores.S, 0);
assert.equal(result.scores.C, 0);
assert.equal(result.label, 'D');
assert.equal(result.percents.D, 100);
});
it('هر چهار تیپ را جدا جمع می‌کند', () => {
const result = scoreDisc({ '1': 'D', '2': 'I', '3': 'S', '4': 'C', '5': 'D', '6': 'I' });
assert.deepEqual(result.scores, { D: 2, I: 2, S: 1, C: 1 });
assert.equal(result.validCount, 6);
assert.equal(result.label, 'D/I');
});
it('در تساوی نتیجه ترکیبی می‌دهد', () => {
const result = scoreDisc({ '1': 'S', '2': 'C', '3': 'S', '4': 'C' });
assert.equal(result.reliable, true);
assert.equal(result.label, 'S/C');
});
it('با کمتر از چهار پاسخ نتیجه قابل اتکا نمی‌دهد', () => {
const result = scoreDisc({ '1': 'D', '2': 'D', '3': 'D' });
assert.equal(result.reliable, false);
assert.equal(result.label, 'اطلاعات ناکافی برای نتیجه قابل اتکا');
assert.deepEqual(result.primaryTypes, []);
});
});
describe('دستیار فروش', () => {
it('برای نیاز ندارم سؤال کشف نیاز می‌دهد و قیمت نمی‌سازد', () => {
const analysis = analyzeCopilot({
topic: 'objection',
goal: 'discover_need',
customerStatement: 'فعلاً نیازی ندارم؛ همین روش فعلی برای من جواب می‌دهد.',
facts: { customerName: 'مهدی', stageName: 'سرنخ جدید' },
});
assert.equal(analysis.objection_category, 'no_need');
assert.match(analysis.recommended_question, /روش فعلی/);
assert.doesNotMatch(analysis.suggested_response, /\d{3,}/);
assert.equal(analysis.disc_context, null);
});
it('DISC تازه را راهنما می‌کند نه تشخیص قطعی', () => {
const analysis = analyzeCopilot({
topic: 'pricing',
goal: 'follow_price',
customerStatement: 'قیمتتان بالاست.',
facts: {
customerName: 'مهدی',
priceOffered: null,
disc: {
label: 'D',
primaryTypes: ['D'],
reliable: true,
assessedAt: new Date().toISOString(),
},
},
});
assert.equal(analysis.disc_context?.used, true);
assert.match(analysis.suggested_response, /کوتاه می‌گویم/);
assert.match(analysis.confidence_note, /قطعی/);
assert.match(analysis.suggested_response, /عدد یا تخفیف جدیدی پیشنهاد نمی‌شود/);
});
it('حرف کوتاه را قطعی تحلیل نمی‌کند', () => {
const analysis = analyzeCopilot({
topic: 'intro',
goal: 'discover_need',
customerStatement: 'نه',
facts: {},
});
assert.equal(analysis.needs_more_input, true);
assert.equal(analysis.suggested_response, '');
});
it('متن پیگیری قیمت را از خودش نمی‌سازد', () => {
const analysis = analyzeCopilot({
topic: 'pricing',
goal: 'follow_price',
customerStatement: 'باید فکر کنم.',
facts: { customerName: 'سارا', productName: 'منتکت' },
});
const text = followUpMessage('price_followup', { customerName: 'سارا', productName: 'منتکت' }, analysis);
assert.match(text, /منتکت/);
assert.match(text, /عدد قیمت را اینجا تکرار نمی‌کنم/);
});
});