Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets.
1260 lines
44 KiB
TypeScript
1260 lines
44 KiB
TypeScript
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,
|
||
};
|
||
}
|