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
+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(),