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