Files
CRM_Montzar/apps/api/src/ai/ai-ask.service.ts
T
Mahdi 09a60b3b5a feat(acl): deep data access by section for roles and users
Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets.
2026-10-22 11:53:26 +03:30

1260 lines
44 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
ActivityType,
AskMentoResponse,
AskMentoSourceDto,
PersonAiSummaryDto,
VoiceActivityDraft,
VoiceCommandDraft,
VoicePersonDraft,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { leadsDeepWhere } from '../common/data-access';
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 {
addJalaliMonths,
formatTehranJalali,
rewriteGregorianDates,
startOfJalaliMonth,
toGregorian,
toJalali,
} from '../common/jalali';
import { AiService } from './ai.service';
import {
classifyCountQuestion,
personSourceKeys,
type CountIntent,
} from './ask-count';
import {
activityPriorWhere,
dealPriorWhere,
} from '../common/prior-archive';
type CatalogItem = AskMentoSourceDto;
type PeriodRange = { key: string; label: string; start: Date; end: Date };
type PeriodCounts = {
today: number;
yesterday: number;
week: number;
month: number;
};
function gregorianKey(gy: number, gm: number, gd: number) {
return `${gy}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
}
function clip(value: string | null | undefined, max = 280) {
const text = (value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
return text.length > max ? `${text.slice(0, max)}…` : text;
}
function roleLabel(role: string) {
return USER_ROLE_LABELS[role as UserRole] ?? role;
}
function planLabel(code: string) {
return BILLING_PLAN_LABELS[code as BillingPlanCode] ?? code;
}
function addDaysToDayKey(dayKey: string, days: number): string {
const [y, m, d] = dayKey.split('-').map(Number);
const dt = new Date(Date.UTC(y!, m! - 1, d!));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}
function dayBoundsFromKey(dayKey: string) {
return {
start: new Date(`${dayKey}T00:00:00+03:30`),
end: new Date(`${dayKey}T23:59:59.999+03:30`),
};
}
/** بازه‌های تقویمی ایران: امروز، دیروز، این هفته (شنبه تا جمعه)، این ماه شمسی */
function tehranPeriods(now = new Date()) {
const dayKey = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const weekday = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
weekday: 'short',
}).format(now);
const sinceSaturday: Record<string, number> = {
Sat: 0,
Sun: 1,
Mon: 2,
Tue: 3,
Wed: 4,
Thu: 5,
Fri: 6,
};
const weekOffset = sinceSaturday[weekday] ?? 0;
const yesterdayKey = addDaysToDayKey(dayKey, -1);
const weekStartKey = addDaysToDayKey(dayKey, -weekOffset);
const weekEndKey = addDaysToDayKey(weekStartKey, 6);
const today = { key: dayKey, label: 'today', ...dayBoundsFromKey(dayKey) };
const yesterday = {
key: yesterdayKey,
label: 'yesterday',
...dayBoundsFromKey(yesterdayKey),
};
const week: PeriodRange = {
key: `${weekStartKey}..${weekEndKey}`,
label: 'this_week',
start: dayBoundsFromKey(weekStartKey).start,
end: dayBoundsFromKey(weekEndKey).end,
};
const lastWeekStartKey = addDaysToDayKey(weekStartKey, -7);
const lastWeekEndKey = addDaysToDayKey(weekStartKey, -1);
const lastWeek: PeriodRange = {
key: `${lastWeekStartKey}..${lastWeekEndKey}`,
label: 'last_week',
start: dayBoundsFromKey(lastWeekStartKey).start,
end: dayBoundsFromKey(lastWeekEndKey).end,
};
const { jy, jm } = (() => {
const [gy, gm, gd] = dayKey.split('-').map(Number);
return toJalali(gy!, gm!, gd!);
})();
const monthStart = startOfJalaliMonth(jy, jm);
const month: PeriodRange = {
key: `${jy}-${String(jm).padStart(2, '0')}`,
label: 'this_jalali_month',
start: monthStart,
end: now,
};
const prev = addJalaliMonths(jy, jm, -1);
const prevStart = toGregorian(prev.jy, prev.jm, 1);
const nextAfterPrev = addJalaliMonths(prev.jy, prev.jm, 1);
const nextStart = toGregorian(nextAfterPrev.jy, nextAfterPrev.jm, 1);
const lastMonthEndKey = addDaysToDayKey(
gregorianKey(nextStart.gy, nextStart.gm, nextStart.gd),
-1,
);
const lastMonth: PeriodRange = {
key: `${prev.jy}-${String(prev.jm).padStart(2, '0')}`,
label: 'last_jalali_month',
start: dayBoundsFromKey(gregorianKey(prevStart.gy, prevStart.gm, prevStart.gd)).start,
end: dayBoundsFromKey(lastMonthEndKey).end,
};
return { now, dayKey, today, yesterday, week, lastWeek, month, lastMonth };
}
function formatPeriodCounts(labelFa: string, counts: PeriodCounts) {
return `${labelFa}: today=${counts.today} | yesterday=${counts.yesterday} | this_week=${counts.week} | this_month=${counts.month}`;
}
@Injectable()
export class AiAskService {
constructor(
private readonly prisma: PrismaService,
private readonly ai: AiService,
) {}
async ask(
actor: Actor,
question: string,
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()
.slice(0, 3500);
const raw = await this.ai.completeJson<{
answer?: string;
sources?: Array<{ type?: string; id?: string; label?: string }>;
}>(
`You are ${brand.name}, a Persian CRM assistant for this user's account.
"محیط" means a workspace/organization the user belongs to.
"مشتری" 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), last_week (the previous Saturday–Friday), this_month (current Jalali month), last_month (the previous Jalali month).
Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates.
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 }] }
Use at most 4 sources, and only ids that appear in the context. Workspace/period facts do not need sources; when naming customers, prefer person sources.
Write the answer in Persian, concise, with exact numbers and names when present.`,
`Question:\n${question.trim()}\n\nCRM context:\n${context}${
chatBlock
? `\n\nRecent conversation (for context only, not CRM facts):\n${chatBlock}`
: ''
}`,
);
const allowed = new Map(catalog.map((item) => [item.id, item]));
const sources = (Array.isArray(raw.sources) ? raw.sources : [])
.map((item) => allowed.get(String(item?.id ?? '')))
.filter((item): item is CatalogItem => Boolean(item))
.slice(0, 4);
const answer = rewriteGregorianDates(String(raw.answer ?? '').trim()).slice(0, 2000);
return {
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
sources,
};
}
async summarizePerson(actor: Actor, personId: string): Promise<PersonAiSummaryDto> {
const { context } = await this.buildContext(actor, personId);
const raw = await this.ai.completeJson<{
text?: string;
cta?: string;
ctaKind?: string;
}>(
`You write a short live briefing for a CRM customer card in Persian.
Return JSON: { "text": string, "cta": string, "ctaKind": "call"|"activity" }
Rules:
- 2 to 4 short sentences from the context only.
- Mention durable memory, last contact, open deals, or next follow-up when present.
- cta is one concrete next step, starting with «${brand.suggestionPrefixFa}».
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
- Do not invent facts.
- Every date must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱. Never write Gregorian month names such as سپتامبر or September.`,
`Customer briefing context:\n${context}`,
);
const text = rewriteGregorianDates(String(raw.text ?? '').trim()).slice(0, 700);
const cta = rewriteGregorianDates(String(raw.cta ?? '').trim()).slice(0, 120);
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
return {
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
cta: cta || brandSuggestionFa('امروز با مشتری تماس بگیر'),
ctaKind,
};
}
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) {
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;
if (key === 'last_week') return periods.lastWeek;
if (key === 'last_month') return periods.lastMonth;
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 === 'last_week'
? 'هفته گذشته'
: intent.period === 'month'
? 'این ماه'
: intent.period === 'last_month'
? 'ماه گذشته'
: '';
if (intent.entity === 'person' || intent.entity === 'company') {
const shared = {
organizationId: orgId,
...ownerWhere(actor),
...(intent.personSource ? { importSource: intent.personSource } : {}),
...created,
};
const n =
intent.entity === 'person'
? await this.prisma.person.count({
where: {
...shared,
...(intent.mood ? { mood: intent.mood } : {}),
},
})
: await this.prisma.company.count({
where: {
...shared,
},
});
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 = {
AND: [
{
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 }] }
: {}),
},
dealPriorWhere(actor.priorLens, actor.priorCutoff),
],
};
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: {
AND: [
{
organizationId: orgId,
...created,
},
assigneeWhere(actor),
activityPriorWhere(actor.priorLens, actor.priorCutoff),
],
},
}),
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 }),
]);
if (periodNote) {
return `لیدهای ${periodNote}: ${this.fa(total)} تا. ${this.fa(open)} تا هنوز باز، ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
}
return `لیدهای باز: ${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>,
): Promise<PeriodCounts> {
const [today, yesterday, week, month] = await Promise.all([
count(periods.today.start, periods.today.end),
count(periods.yesterday.start, periods.yesterday.end),
count(periods.week.start, periods.week.end),
count(periods.month.start, periods.month.end),
]);
return { today, yesterday, week, month };
}
private async buildContext(actor: Actor, personId?: string) {
if (personId) {
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
include: { company: { select: { name: true } } },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
}
const periods = tehranPeriods();
const { now, dayKey } = periods;
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals');
const activityScope = assigneeWhere(actor);
const personScope = ownerWhere(actor);
const companyScope = ownerWhere(actor);
const invoiceScope = ownerWhere(actor, 'finance');
const quoteScope = ownerWhere(actor, 'finance');
const ticketScope = ticketAccessWhere(actor);
const createdBetween =
(start: Date, end: Date) => ({ createdAt: { gte: start, lte: end } });
const [
org,
user,
memberships,
ownerBilling,
teamCount,
peopleTotal,
companiesTotal,
productsTotal,
openDealCount,
staleDealCount,
overdueActivityCount,
activitiesDueToday,
peoplePeriods,
companyPeriods,
activityPeriods,
dealCreatedPeriods,
dealWonPeriods,
invoicePeriods,
quotePeriods,
productPeriods,
notePeriods,
ticketPeriods,
peopleToday,
recentPeople,
notes,
activities,
deals,
memories,
] = await Promise.all([
this.prisma.organization.findUnique({
where: { id: orgId },
select: { name: true, businessField: true },
}),
this.prisma.user.findUnique({
where: { id: actor.userId },
select: { fullName: true, email: true, mobile: true },
}),
this.prisma.membership.findMany({
where: {
userId: actor.userId,
isActive: true,
organization: { isActive: true },
},
select: {
role: true,
organizationId: true,
organization: { select: { name: true } },
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.billingAccount.findFirst({
where: {
owner: {
memberships: {
some: {
organizationId: orgId,
role: 'owner',
isActive: true,
},
},
},
},
select: {
planCode: true,
status: true,
expiresAt: true,
maxOrganizations: true,
},
}),
this.prisma.membership.count({
where: { organizationId: orgId, isActive: true },
}),
this.prisma.person.count({
where: { organizationId: orgId, ...personScope },
}),
this.prisma.company.count({
where: { organizationId: orgId, ...companyScope },
}),
this.prisma.product.count({
where: { organizationId: orgId },
}),
this.prisma.deal.count({
where: { organizationId: orgId, status: 'open', ...dealScope },
}),
this.prisma.deal.count({
where: {
organizationId: orgId,
status: 'open',
...dealScope,
OR: [{ isStale: true }, { isRotten: true }],
},
}),
this.prisma.activity.count({
where: {
organizationId: orgId,
...activityScope,
status: 'open',
dueAt: { lt: now },
},
}),
this.prisma.activity.count({
where: {
organizationId: orgId,
...activityScope,
status: 'open',
dueAt: { gte: periods.today.start, lte: periods.today.end },
},
}),
this.countInPeriods(
(start, end) =>
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.company.count({
where: {
organizationId: orgId,
...companyScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.activity.count({
where: {
organizationId: orgId,
...activityScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.deal.count({
where: {
organizationId: orgId,
...dealScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.deal.count({
where: {
organizationId: orgId,
...dealScope,
status: 'won',
updatedAt: { gte: start, lte: end },
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.invoice.count({
where: {
organizationId: orgId,
...invoiceScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.quote.count({
where: {
organizationId: orgId,
...quoteScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.product.count({
where: {
organizationId: orgId,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.note.count({
where: {
organizationId: orgId,
...noteAccessWhere(actor),
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.ticket.count({
where: {
organizationId: orgId,
...ticketScope,
...createdBetween(start, end),
},
}),
periods,
),
this.prisma.person.findMany({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: periods.today.start, lte: periods.today.end },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.prisma.person.findMany({
where: { organizationId: orgId, ...personScope },
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 8,
}),
this.prisma.note.findMany({
where: {
organizationId: orgId,
...noteAccessWhere(actor),
...(personId ? { personId } : {}),
},
select: {
id: true,
title: true,
body: true,
createdAt: true,
person: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: personId ? 15 : 8,
}),
this.prisma.activity.findMany({
where: {
organizationId: orgId,
...assigneeWhere(actor),
...(personId ? { personId } : {}),
},
select: {
id: true,
type: true,
status: true,
title: true,
description: true,
dueAt: true,
person: { select: { firstName: true, lastName: true } },
},
orderBy: { updatedAt: 'desc' },
take: personId ? 15 : 10,
}),
this.prisma.deal.findMany({
where: {
organizationId: orgId,
...ownerWhere(actor, 'deals'),
...(personId ? { personId } : { status: 'open' }),
},
select: {
id: true,
title: true,
amount: true,
currency: true,
status: true,
isStale: true,
isRotten: true,
person: { select: { firstName: true, lastName: true } },
stage: { select: { name: true } },
},
orderBy: { updatedAt: 'desc' },
take: 10,
}),
personId
? this.prisma.memoryItem.findMany({
where: { organizationId: orgId, personId },
select: { id: true, content: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 8,
})
: Promise.resolve([]),
]);
const catalog: CatalogItem[] = [];
const field =
org?.businessField && isBusinessField(org.businessField)
? BUSINESS_FIELD_LABELS[org.businessField]
: 'نامشخص';
const envLines = memberships.map((row) => {
const current = row.organizationId === orgId ? ' [current]' : '';
return `${row.organization.name}${current} (role=${roleLabel(row.role)})`;
});
const billingDays = ownerBilling
? Math.max(
0,
Math.ceil(
(ownerBilling.expiresAt.getTime() - now.getTime()) /
(24 * 60 * 60 * 1000),
),
)
: null;
const peopleTodayNames = peopleToday
.map((p) => `${p.firstName} ${p.lastName}`.trim())
.filter(Boolean);
const lines: string[] = [
`Today (Asia/Tehran, Jalali): ${formatTehranJalali(now)} | gregorian_key=${dayKey}`,
`Periods: yesterday=${periods.yesterday.key} | this_week=${periods.week.key} (Sat–Fri) | this_month=${periods.month.key} (Jalali)`,
'Workspace facts (authoritative):',
`User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`,
`Environments (محیط) count: ${memberships.length}`,
`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
? `New customers today names: ${peopleTodayNames.join('، ')}`
: 'New customers today names: (none)',
formatPeriodCounts('Companies / شرکت‌ها', companyPeriods),
formatPeriodCounts('Activities / فعالیت‌ها (created)', activityPeriods),
formatPeriodCounts('Deals / معاملات (created)', dealCreatedPeriods),
formatPeriodCounts('Won deals / معاملات برنده (updatedAt while won)', dealWonPeriods),
formatPeriodCounts('Invoices / فاکتورها', invoicePeriods),
formatPeriodCounts('Quotes / پیش‌فاکتورها', quotePeriods),
formatPeriodCounts('Products / محصولات', productPeriods),
formatPeriodCounts('Notes / یادداشت‌ها', notePeriods),
formatPeriodCounts('Tickets / تیکت‌ها', ticketPeriods),
];
if (ownerBilling && billingDays != null) {
lines.push(
`Subscription of current environment owner: ${planLabel(ownerBilling.planCode)} | status=${ownerBilling.status} | days remaining=${billingDays} | max environments=${ownerBilling.maxOrganizations}`,
);
}
lines.push(
personId
? `Focus personId: ${personId}`
: 'Scope: current user records in the current environment',
);
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: orgId },
include: { company: { select: { name: true } } },
});
if (person) {
catalog.push({
type: 'person',
id: person.id,
label: `${person.firstName} ${person.lastName}`.trim(),
});
lines.push(
`Person: ${person.firstName} ${person.lastName} | position: ${person.position ?? '—'} | company: ${person.company?.name ?? '—'} | mood: ${person.mood} | phone: ${person.mobile || person.phone || '—'} | email: ${person.email ?? '—'}`,
);
}
} else {
const seenPeople = new Set<string>();
for (const p of [...peopleToday, ...recentPeople]) {
if (seenPeople.has(p.id)) continue;
seenPeople.add(p.id);
const label = `${p.firstName} ${p.lastName}`.trim();
catalog.push({ type: 'person', id: p.id, label });
lines.push(
`Person ${p.id} ${label} created_jalali=${formatTehranJalali(p.createdAt)} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
);
}
}
for (const item of memories) {
catalog.push({ type: 'memory', id: item.id, label: clip(item.content, 60) });
lines.push(`Memory ${item.id}: ${clip(item.content, 220)}`);
}
for (const note of notes) {
const who = note.person
? `${note.person.firstName} ${note.person.lastName}`
: '';
catalog.push({
type: 'note',
id: note.id,
label: clip(note.title || note.body, 60),
});
lines.push(
`Note ${note.id} (jalali=${formatTehranJalali(note.createdAt)})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
);
}
for (const activity of activities) {
const who = activity.person
? `${activity.person.firstName} ${activity.person.lastName}`
: '';
catalog.push({ type: 'activity', id: activity.id, label: activity.title });
lines.push(
`Activity ${activity.id} type=${activity.type} status=${activity.status} due_jalali=${activity.dueAt ? formatTehranJalali(activity.dueAt) : '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
);
}
for (const deal of deals) {
const who = deal.person
? `${deal.person.firstName} ${deal.person.lastName}`
: '';
catalog.push({ type: 'deal', id: deal.id, label: deal.title });
const flags = [
deal.isStale ? 'stale' : null,
deal.isRotten ? 'rotten' : null,
]
.filter(Boolean)
.join(',');
lines.push(
`Deal ${deal.id} ${deal.title} status=${deal.status} stage=${deal.stage.name} amount=${deal.amount != null ? String(deal.amount) : '—'} ${deal.currency}${flags ? ` flags=${flags}` : ''}${who ? ` person=${who}` : ''}`,
);
}
return { context: lines.join('\n').slice(0, 14000), catalog };
}
async parseVoiceCommand(actor: Actor, text: string): Promise<VoiceCommandDraft> {
const now = new Date();
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const raw = await this.ai.completeJson<{
intent?: string;
summary?: string;
person?: Partial<VoicePersonDraft> | null;
activity?: Partial<VoiceActivityDraft> | null;
}>(
`You convert one Persian voice command into a CRM action draft.
Return ONLY JSON:
{
"intent": "create_person" | "create_activity" | "none",
"summary": string,
"person": { "firstName": string, "lastName": string, "mobile": string, "phone": string, "email": string, "website": string, "referrer": string, "position": string, "notes": string } | null,
"activity": { "title": string, "type": "call"|"meeting"|"task"|"note", "description": string, "dueAt": string|null, "personName": string } | null
}
Rules:
- create_person only when the user asks to add/create a customer or contact.
- create_activity only when the user asks to add a follow-up, call, meeting, or task.
- Otherwise intent is none. Do not invent a record.
- Do not invent phone numbers, emails, or names that were not spoken.
- Mobile/phone: digits only, Iranian mobile as 09xxxxxxxxx.
- dueAt: ISO 8601 in Asia/Tehran if a time is mentioned, else null. Today is ${today}.
- summary: one short Persian sentence describing what will be saved.
- Unused string fields are empty strings.`,
text.trim(),
);
const intent =
raw.intent === 'create_person' || raw.intent === 'create_activity'
? raw.intent
: 'none';
if (intent === 'create_person') {
const person = cleanPerson(raw.person);
const missing: string[] = [];
if (!person.firstName) missing.push('نام');
return {
intent,
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
missing,
canConfirm: missing.length === 0,
person,
activity: null,
};
}
if (intent === 'create_activity') {
const activity = cleanActivity(raw.activity);
if (activity.personName) {
activity.personId = await this.matchPersonId(actor, activity.personName);
}
const missing: string[] = [];
if (!activity.title) missing.push('عنوان فعالیت');
return {
intent,
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ثبت فعالیت «${activity.title}»`,
missing,
canConfirm: missing.length === 0,
person: null,
activity,
};
}
return {
intent: 'none',
summary:
rewriteGregorianDates((raw.summary ?? '').trim()) ||
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام می‌شود.',
missing: [],
canConfirm: false,
person: null,
activity: null,
};
}
private async matchPersonId(actor: Actor, name: string): Promise<string | null> {
const q = name.trim();
if (q.length < 2) return null;
const rows = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
],
},
select: { id: true, firstName: true, lastName: true },
take: 5,
});
const exact = rows.filter((row) => `${row.firstName} ${row.lastName}`.trim() === q);
if (exact.length === 1) return exact[0]!.id;
if (rows.length === 1) return rows[0]!.id;
return null;
}
}
function cleanText(value: unknown, max = 200) {
return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
}
function digitsOnly(value: unknown) {
const fa = '۰۱۲۳۴۵۶۷۸۹';
const ar = '٠١٢٣٤٥٦٧٨٩';
const raw = String(value ?? '')
.replace(/[۰-۹]/g, (ch) => String(fa.indexOf(ch)))
.replace(/[٠-٩]/g, (ch) => String(ar.indexOf(ch)));
let digits = raw.replace(/[^\d+]/g, '');
if (digits.startsWith('+98')) digits = `0${digits.slice(3)}`;
else if (digits.startsWith('98') && digits.length === 12) digits = `0${digits.slice(2)}`;
return digits.slice(0, 20);
}
function cleanPerson(input: Partial<VoicePersonDraft> | null | undefined): VoicePersonDraft {
return {
firstName: cleanText(input?.firstName, 80),
lastName: cleanText(input?.lastName, 80),
mobile: digitsOnly(input?.mobile),
phone: digitsOnly(input?.phone),
email: cleanText(input?.email, 120),
website: cleanText(input?.website, 300),
referrer: cleanText(input?.referrer, 120),
position: cleanText(input?.position, 120),
notes: cleanText(input?.notes, 500),
};
}
const ACTIVITY_TYPES = new Set<ActivityType>(['call', 'meeting', 'task', 'note']);
function cleanActivity(input: Partial<VoiceActivityDraft> | null | undefined): VoiceActivityDraft {
const type = ACTIVITY_TYPES.has(input?.type as ActivityType)
? (input?.type as ActivityType)
: 'task';
const due = cleanText(input?.dueAt, 40);
return {
title: cleanText(input?.title, 160),
type,
description: cleanText(input?.description, 500),
dueAt: due && !Number.isNaN(Date.parse(due)) ? new Date(due).toISOString() : null,
personName: cleanText(input?.personName, 120),
personId: null,
};
}