fix: share the dashboard assistant and count leads from data

The hero ask box now uses the same confirm-before-create path as the assistant, count questions use real filters, and lead mobile, Jalali follow-ups, and rial labels stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 14:45:29 +03:30
co-authored by Cursor
parent 0e420a443f
commit 5f4cc47c41
14 changed files with 1463 additions and 44 deletions
+348 -3
View File
@@ -12,22 +12,33 @@ import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
assigneeWhere,
elevated,
forScope,
noteAccessWhere,
dealAccessWhere,
ownerWhere,
projectCardAccessWhere,
ticketAccessWhere,
} from '../common/record-scope';
import {
BILLING_PLAN_LABELS,
BUSINESS_FIELD_LABELS,
PERSON_IMPORT_SOURCE_LABELS,
USER_ROLE_LABELS,
brand,
brandSuggestionFa,
isBusinessField,
type BillingPlanCode,
type PersonImportSource,
type UserRole,
} from '@mento/shared';
import { startOfJalaliMonth, toJalali } from '../common/jalali';
import { AiService } from './ai.service';
import {
classifyCountQuestion,
personSourceKeys,
type CountIntent,
} from './ask-count';
type CatalogItem = AskMentoSourceDto;
@@ -136,6 +147,9 @@ export class AiAskService {
personId?: string,
chatContext?: string,
): Promise<AskMentoResponse> {
const direct = await this.directCount(actor, question);
if (direct) return { answer: direct, sources: [] };
const { context, catalog } = await this.buildContext(actor, personId);
const chatBlock = String(chatContext || '')
.trim()
@@ -146,10 +160,13 @@ export class AiAskService {
}>(
`You are ${brand.name}, a Persian CRM assistant for this user's account.
"محیط" means a workspace/organization the user belongs to.
"مشتری" / "مشتری جدید" means a Person (contact) record.
"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total.
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، ته‌حساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
Lead stage and lead source lines are authoritative the same way.
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets.
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable.
Workspace facts and Period stats are complete and authoritative for counts.
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. If a requested filter is not in the facts, say that filtered count is not available instead of substituting another total.
If a "Recent conversation" block is provided, use it only to understand the user's question and tone; do not invent CRM facts from chat alone.
Do not invent customers, amounts, dates, or promises.
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
@@ -203,6 +220,333 @@ Rules:
};
}
private leadWhere(actor: Actor) {
const scoped = forScope(actor, 'leads');
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
return { assigneeId: { in: scoped.visibleUserIds } };
}
return { assigneeId: scoped.userId };
}
private fa(n: number) {
return new Intl.NumberFormat('fa-IR').format(n);
}
private periodRange(key: NonNullable<CountIntent['period']>) {
const periods = tehranPeriods();
if (key === 'today') return periods.today;
if (key === 'yesterday') return periods.yesterday;
if (key === 'week') return periods.week;
return periods.month;
}
private async directCount(actor: Actor, question: string): Promise<string | null> {
const intent = classifyCountQuestion(question);
if (!intent) return null;
const orgId = actor.organizationId;
const created =
intent.period != null
? {
createdAt: {
gte: this.periodRange(intent.period).start,
lte: this.periodRange(intent.period).end,
},
}
: {};
const periodNote =
intent.period === 'today'
? 'امروز'
: intent.period === 'yesterday'
? 'دیروز'
: intent.period === 'week'
? 'این هفته'
: intent.period === 'month'
? 'این ماه'
: '';
if (intent.entity === 'person' || intent.entity === 'company') {
const where = {
organizationId: orgId,
...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)),
...(intent.personSource ? { importSource: intent.personSource } : {}),
...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}),
...created,
};
const n =
intent.entity === 'person'
? await this.prisma.person.count({ where })
: await this.prisma.company.count({ where });
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
const source = intent.personSource
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
: '';
const mood =
intent.mood === 'happy'
? 'خوشحال'
: intent.mood === 'unhappy'
? 'ناراضی'
: intent.mood === 'special'
? 'خاص'
: intent.mood === 'newcomer'
? 'تازه‌وارد'
: '';
const bits = [source, mood, periodNote].filter(Boolean).join(' ');
const label = bits ? `${who}های ${bits}` : `کل ${who}ها`;
return `${label}: ${this.fa(n)} تا.`;
}
if (intent.entity === 'lead') {
return this.directLeadCount(actor, intent, created, periodNote);
}
if (intent.entity === 'deal') {
const scope = dealAccessWhere(actor);
const where = {
organizationId: orgId,
...scope,
...created,
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
...(intent.dealBucket === 'stale'
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
: {}),
};
const n = await this.prisma.deal.count({ where });
const name =
intent.dealBucket === 'open'
? 'معاملات باز'
: intent.dealBucket === 'won'
? 'معاملات برنده'
: intent.dealBucket === 'lost'
? 'معاملات ازدست‌رفته'
: intent.dealBucket === 'stale'
? 'معاملات در خطر'
: 'کل معاملات';
return `${periodNote ? `${name} ${periodNote}` : name}: ${this.fa(n)} تا.`;
}
const simple: Record<string, () => Promise<number>> = {
invoice: () =>
this.prisma.invoice.count({
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
}),
quote: () =>
this.prisma.quote.count({
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
}),
product: () =>
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
activity: () =>
this.prisma.activity.count({
where: { organizationId: orgId, ...assigneeWhere(actor), ...created },
}),
ticket: () =>
this.prisma.ticket.count({
where: { organizationId: orgId, ...ticketAccessWhere(actor), ...created },
}),
note: () =>
this.prisma.note.count({
where: { organizationId: orgId, ...noteAccessWhere(actor), ...created },
}),
cheque: () => this.prisma.cheque.count({ where: { organizationId: orgId, ...created } }),
reservation: () =>
this.prisma.reservationAppointment.count({
where: { organizationId: orgId, ...created },
}),
routine: () =>
this.prisma.routineTask.count({ where: { organizationId: orgId, ...created } }),
project: () =>
this.prisma.projectCard.count({
where: { organizationId: orgId, ...projectCardAccessWhere(actor), ...created },
}),
};
const counter = simple[intent.entity];
if (!counter) return null;
const labels: Record<string, string> = {
invoice: 'فاکتور',
quote: 'پیش‌فاکتور',
product: 'محصول',
activity: 'فعالیت',
ticket: 'تیکت',
note: 'یادداشت',
cheque: 'چک',
reservation: 'رزرو',
routine: 'کار روتین',
project: 'کارت پروژه',
};
const n = await counter();
const name = labels[intent.entity] ?? intent.entity;
return `${periodNote ? `${name}های ${periodNote}` : `کل ${name}ها`}: ${this.fa(n)} تا.`;
}
private async directLeadCount(
actor: Actor,
intent: CountIntent,
created: { createdAt?: { gte: Date; lte: Date } },
periodNote: string,
) {
const orgId = actor.organizationId;
const base = { organizationId: orgId, ...this.leadWhere(actor), ...created };
const [stages, sources] = await Promise.all([
this.prisma.leadStage.findMany({
where: { organizationId: orgId },
select: { id: true, name: true },
}),
this.prisma.lead.groupBy({
by: ['source'],
where: base,
_count: { _all: true },
}),
]);
const compact = (value: string) => value.replace(/[\s‌]+/g, '').toLowerCase();
const hint = compact(intent.hint).length >= 2 ? compact(intent.hint) : '';
const stage = hint
? stages.find((row) => {
const name = compact(row.name);
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
})
: undefined;
const sourceRow = hint
? sources.find((row) => {
const name = compact(row.source ?? '');
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
})
: undefined;
if (hint && !stage && !sourceRow && intent.leadBucket === 'summary') {
return `لیدی با فیلتر «${intent.hint}» پیدا نشد: ${this.fa(0)} تا.`;
}
if (intent.leadBucket === 'summary' && !stage && !sourceRow) {
const [open, archived, converted, total] = await Promise.all([
this.prisma.lead.count({
where: { ...base, archivedAt: null, convertedPersonId: null },
}),
this.prisma.lead.count({ where: { ...base, archivedAt: { not: null } } }),
this.prisma.lead.count({
where: { ...base, convertedPersonId: { not: null }, archivedAt: null },
}),
this.prisma.lead.count({ where: base }),
]);
const when = periodNote ? ` ${periodNote}` : '';
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
}
const where = {
...base,
...(intent.leadBucket === 'archived' ? { archivedAt: { not: null } } : {}),
...(intent.leadBucket === 'converted'
? { convertedPersonId: { not: null }, archivedAt: null }
: {}),
...(intent.leadBucket === 'open'
? { archivedAt: null, convertedPersonId: null }
: {}),
...(stage ? { stageId: stage.id } : {}),
...(sourceRow ? { source: sourceRow.source } : {}),
};
const n = await this.prisma.lead.count({ where });
const bits = [
stage?.name,
sourceRow?.source || '',
intent.leadBucket === 'archived'
? 'بایگانی'
: intent.leadBucket === 'converted'
? 'تبدیل‌شده'
: intent.leadBucket === 'open'
? 'باز'
: '',
periodNote,
].filter(Boolean);
return `لیدهای ${bits.join(' ')}: ${this.fa(n)} تا.`;
}
private async filterFactLines(actor: Actor) {
const orgId = actor.organizationId;
const personScope = ownerWhere(actor);
const companyScope = ownerWhere(actor);
const leadBase = { organizationId: orgId, ...this.leadWhere(actor) };
const [peopleSources, companySources, stages, leadSources, leadCounts, extras] =
await Promise.all([
this.prisma.person.groupBy({
by: ['importSource'],
where: { organizationId: orgId, ...personScope },
_count: { _all: true },
}),
this.prisma.company.groupBy({
by: ['importSource'],
where: { organizationId: orgId, ...companyScope },
_count: { _all: true },
}),
this.prisma.leadStage.findMany({
where: { organizationId: orgId },
select: { id: true, name: true },
orderBy: { position: 'asc' },
}),
this.prisma.lead.groupBy({
by: ['source'],
where: leadBase,
_count: { _all: true },
}),
Promise.all([
this.prisma.lead.count({ where: leadBase }),
this.prisma.lead.count({
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
}),
this.prisma.lead.count({ where: { ...leadBase, archivedAt: { not: null } } }),
this.prisma.lead.count({
where: { ...leadBase, convertedPersonId: { not: null }, archivedAt: null },
}),
]),
Promise.all([
this.prisma.cheque.count({ where: { organizationId: orgId } }),
this.prisma.reservationAppointment.count({ where: { organizationId: orgId } }),
this.prisma.routineTask.count({ where: { organizationId: orgId } }),
this.prisma.projectCard.count({ where: { organizationId: orgId } }),
]),
]);
const sourceLine = (
label: string,
rows: Array<{ importSource: string; _count: { _all: number } }>,
) => {
const parts = personSourceKeys().map((key) => {
const hit = rows.find((row) => (row.importSource || 'manual') === key);
const name = PERSON_IMPORT_SOURCE_LABELS[key as PersonImportSource];
return `${name}/${key}=${hit?._count._all ?? 0}`;
});
return `${label}: ${parts.join(' | ')}`;
};
const stageCounts = await this.prisma.lead.groupBy({
by: ['stageId'],
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
_count: { _all: true },
});
const stageLine = stages
.map((stage) => {
const hit = stageCounts.find((row) => row.stageId === stage.id);
return `${stage.name}=${hit?._count._all ?? 0}`;
})
.join(' | ');
const sourceBits = leadSources
.map((row) => `${row.source?.trim() || '(بدون منبع)'}=${row._count._all}`)
.join(' | ');
const [leadTotal, leadOpen, leadArchived, leadConverted] = leadCounts;
const [cheques, reservations, routines, projects] = extras;
return [
'People by import source (مشتری؛ دستی=manual). Use the matching source, never the people total, when a source is named:',
sourceLine('People sources', peopleSources),
sourceLine('Company sources', companySources),
`Leads are NOT people. open=${leadOpen} | archived=${leadArchived} | converted=${leadConverted} | total=${leadTotal}`,
`Lead stages (open only): ${stageLine || 'none'}`,
`Lead sources: ${sourceBits || 'none'}`,
`Other totals: cheques=${cheques} | reservations=${reservations} | routine tasks=${routines} | project cards=${projects}`,
];
}
private async countInPeriods(
count: (start: Date, end: Date) => Promise<number>,
periods: ReturnType<typeof tehranPeriods>,
@@ -589,6 +933,7 @@ Rules:
`Environments: ${envLines.join(' | ') || 'none'}`,
`Current environment: ${org?.name ?? '—'} | business field: ${field} | team members: ${teamCount}`,
`Totals: people=${peopleTotal} | companies=${companiesTotal} | products=${productsTotal} | open deals=${openDealCount} | at-risk deals=${staleDealCount} | overdue activities=${overdueActivityCount} | activities due today=${activitiesDueToday}`,
...(await this.filterFactLines(actor)),
'Period stats (created/registered unless noted):',
formatPeriodCounts('People / مشتریان جدید', peoplePeriods),
peopleTodayNames.length