2026-09-03 14:20:55 +03:30
import { Injectable , NotFoundException } from '@nestjs/common' ;
import type {
2026-09-23 15:49:44 +03:30
ActivityType ,
2026-09-03 14:20:55 +03:30
AskMentoResponse ,
AskMentoSourceDto ,
PersonAiSummaryDto ,
2026-09-23 15:49:44 +03:30
VoiceActivityDraft ,
VoiceCommandDraft ,
VoicePersonDraft ,
2026-09-03 14:20:55 +03:30
} from '@mento/shared' ;
import { PrismaService } from '../prisma/prisma.service' ;
import {
type Actor ,
assigneeWhere ,
2026-10-13 14:45:29 +03:30
elevated ,
forScope ,
2026-09-03 14:20:55 +03:30
noteAccessWhere ,
2026-10-13 14:45:29 +03:30
dealAccessWhere ,
2026-09-03 14:20:55 +03:30
ownerWhere ,
2026-10-13 14:45:29 +03:30
projectCardAccessWhere ,
2026-09-21 19:24:39 +03:30
ticketAccessWhere ,
2026-09-03 14:20:55 +03:30
} from '../common/record-scope' ;
2026-09-21 16:58:49 +03:30
import {
BILLING_PLAN_LABELS ,
BUSINESS_FIELD_LABELS ,
2026-10-13 14:45:29 +03:30
PERSON_IMPORT_SOURCE_LABELS ,
2026-09-21 16:58:49 +03:30
USER_ROLE_LABELS ,
brand ,
brandSuggestionFa ,
isBusinessField ,
type BillingPlanCode ,
2026-10-13 14:45:29 +03:30
type PersonImportSource ,
2026-09-21 16:58:49 +03:30
type UserRole ,
} from '@mento/shared' ;
2026-10-13 18:04:53 +03:30
import { formatTehranJalali , rewriteGregorianDates , startOfJalaliMonth , toJalali } from '../common/jalali' ;
2026-09-03 14:20:55 +03:30
import { AiService } from './ai.service' ;
2026-10-13 14:45:29 +03:30
import {
classifyCountQuestion ,
personSourceKeys ,
type CountIntent ,
} from './ask-count' ;
2026-09-03 14:20:55 +03:30
type CatalogItem = AskMentoSourceDto ;
2026-09-21 19:24:39 +03:30
type PeriodRange = { key : string ; label : string ; start : Date ; end : Date };
type PeriodCounts = {
today : number ;
yesterday : number ;
week : number ;
month : number ;
};
2026-09-03 14:20:55 +03:30
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 ;
}
2026-09-21 16:58:49 +03:30
function roleLabel ( role : string ) {
return USER_ROLE_LABELS [ role as UserRole ] ?? role ;
}
function planLabel ( code : string ) {
return BILLING_PLAN_LABELS [ code as BillingPlanCode ] ?? code ;
}
2026-09-21 19:24:39 +03:30
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 { 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 ,
};
return { now , dayKey , today , yesterday , week , month };
}
function formatPeriodCounts ( labelFa : string , counts : PeriodCounts ) {
return ` ${ labelFa } : today= ${ counts . today } | yesterday= ${ counts . yesterday } | this_week= ${ counts . week } | this_month= ${ counts . month } ` ;
}
2026-09-03 14:20:55 +03:30
@Injectable ()
export class AiAskService {
constructor (
private readonly prisma : PrismaService ,
private readonly ai : AiService ,
) {}
async ask (
actor : Actor ,
question : string ,
personId? : string ,
2026-09-30 16:30:41 +03:30
chatContext? : string ,
2026-09-03 14:20:55 +03:30
) : Promise < AskMentoResponse > {
2026-10-13 14:45:29 +03:30
const direct = await this . directCount ( actor , question );
if ( direct ) return { answer : direct , sources : [] };
2026-09-03 14:20:55 +03:30
const { context , catalog } = await this . buildContext ( actor , personId );
2026-09-30 16:30:41 +03:30
const chatBlock = String ( chatContext || '' )
. trim ()
. slice ( 0 , 3500 );
2026-09-03 14:20:55 +03:30
const raw = await this . ai . completeJson < {
answer? : string ;
sources? : Array < { type ?: string ; id? : string ; label? : string } > ;
} > (
2026-09-21 16:58:49 +03:30
`You are ${ brand . name } , a Persian CRM assistant for this user's account.
2026-09-21 19:24:39 +03:30
"محیط" means a workspace/organization the user belongs to.
2026-10-13 14:45:29 +03:30
"مشتری" 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.
2026-09-21 19:24:39 +03:30
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday– Friday), this_month (current Jalali month).
2026-10-13 18:04:53 +03:30
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.
2026-10-13 14:45:29 +03:30
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.
2026-09-30 16:30:41 +03:30
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.
2026-09-03 14:20:55 +03:30
Do not invent customers, amounts, dates, or promises.
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
2026-09-21 19:24:39 +03:30
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.
2026-09-21 16:58:49 +03:30
Write the answer in Persian, concise, with exact numbers and names when present.` ,
2026-09-30 16:30:41 +03:30
`Question: \ n ${ question . trim () } \ n \ nCRM context: \ n ${ context }${
chatBlock
? ` \ n \ nRecent conversation (for context only, not CRM facts): \ n ${ chatBlock } `
: ''
} ` ,
2026-09-03 14:20:55 +03:30
);
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 );
2026-10-13 18:04:53 +03:30
const answer = rewriteGregorianDates ( String ( raw . answer ?? '' ). trim ()). slice ( 0 , 2000 );
2026-09-03 14:20:55 +03:30
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.
2026-09-07 11:49:54 +03:30
- cta is one concrete next step, starting with « ${ brand . suggestionPrefixFa } ».
2026-09-03 14:20:55 +03:30
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
2026-10-13 18:04:53 +03:30
- Do not invent facts.
- Every date must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱. Never write Gregorian month names such as سپتامبر or September.` ,
2026-09-03 14:20:55 +03:30
`Customer briefing context: \ n ${ context } ` ,
);
2026-10-13 18:04:53 +03:30
const text = rewriteGregorianDates ( String ( raw . text ?? '' ). trim ()). slice ( 0 , 700 );
const cta = rewriteGregorianDates ( String ( raw . cta ?? '' ). trim ()). slice ( 0 , 120 );
2026-09-03 14:20:55 +03:30
const ctaKind = raw . ctaKind === 'activity' ? 'activity' : 'call' ;
return {
text : text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.' ,
2026-09-07 11:49:54 +03:30
cta : cta || brandSuggestionFa ( 'امروز با مشتری تماس بگیر' ),
2026-09-03 14:20:55 +03:30
ctaKind ,
};
}
2026-10-13 14:45:29 +03:30
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 } ` ,
];
}
2026-09-21 19:24:39 +03:30
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 };
}
2026-09-03 14:20:55 +03:30
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 ( 'شخص پیدا نشد' );
}
2026-09-21 19:24:39 +03:30
const periods = tehranPeriods ();
const { now , dayKey } = periods ;
const orgId = actor . organizationId ;
2026-10-06 15:04:46 +03:30
const dealScope = ownerWhere ( actor , 'deals' );
2026-09-21 16:58:49 +03:30
const activityScope = assigneeWhere ( actor );
const personScope = ownerWhere ( actor );
2026-09-21 19:24:39 +03:30
const companyScope = ownerWhere ( actor );
2026-10-06 15:04:46 +03:30
const invoiceScope = ownerWhere ( actor , 'finance' );
const quoteScope = ownerWhere ( actor , 'finance' );
2026-09-21 19:24:39 +03:30
const ticketScope = ticketAccessWhere ( actor );
const createdBetween =
( start : Date , end : Date ) => ({ createdAt : { gte : start , lte : end } });
2026-09-21 16:58:49 +03:30
const [
org ,
user ,
memberships ,
ownerBilling ,
2026-09-21 19:24:39 +03:30
teamCount ,
peopleTotal ,
companiesTotal ,
productsTotal ,
2026-09-21 16:58:49 +03:30
openDealCount ,
staleDealCount ,
overdueActivityCount ,
2026-09-21 19:24:39 +03:30
activitiesDueToday ,
peoplePeriods ,
companyPeriods ,
activityPeriods ,
dealCreatedPeriods ,
dealWonPeriods ,
invoicePeriods ,
quotePeriods ,
productPeriods ,
notePeriods ,
ticketPeriods ,
peopleToday ,
recentPeople ,
2026-09-21 16:58:49 +03:30
notes ,
activities ,
deals ,
memories ,
] = await Promise . all ([
2026-09-03 16:45:59 +03:30
this . prisma . organization . findUnique ({
2026-09-21 19:24:39 +03:30
where : { id : orgId },
2026-09-03 16:45:59 +03:30
select : { name : true , businessField : true },
}),
2026-09-21 16:58:49 +03:30
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 : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
role : 'owner' ,
isActive : true ,
},
},
},
},
select : {
planCode : true ,
status : true ,
expiresAt : true ,
maxOrganizations : true ,
},
}),
2026-09-21 19:24:39 +03:30
this . prisma . membership . count ({
where : { organizationId : orgId , isActive : true },
}),
2026-09-21 16:58:49 +03:30
this . prisma . person . count ({
2026-09-21 19:24:39 +03:30
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 },
2026-09-21 16:58:49 +03:30
}),
this . prisma . deal . count ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
status : 'open' ,
... dealScope ,
OR : [{ isStale : true }, { isRotten : true }],
},
}),
this . prisma . activity . count ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
... activityScope ,
status : 'open' ,
dueAt : { lt : now },
},
}),
this . prisma . activity . count ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
... activityScope ,
status : 'open' ,
2026-09-21 19:24:39 +03:30
dueAt : { gte : periods.today.start , lte : periods.today.end },
2026-09-21 16:58:49 +03:30
},
}),
2026-09-21 19:24:39 +03:30
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 ,
2026-09-21 16:58:49 +03:30
}),
2026-09-03 14:20:55 +03:30
this . prisma . note . findMany ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-03 14:20:55 +03:30
... 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 : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-03 14:20:55 +03:30
... 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 : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-10-06 15:04:46 +03:30
... ownerWhere ( actor , 'deals' ),
2026-09-03 14:20:55 +03:30
...( 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 ({
2026-09-21 19:24:39 +03:30
where : { organizationId : orgId , personId },
2026-09-03 14:20:55 +03:30
select : { id : true , content : true , updatedAt : true },
orderBy : { updatedAt : 'desc' },
take : 8 ,
})
: Promise . resolve ([]),
]);
const catalog : CatalogItem [] = [];
2026-09-03 16:45:59 +03:30
const field =
org ? . businessField && isBusinessField ( org . businessField )
? BUSINESS_FIELD_LABELS [ org . businessField ]
: 'نامشخص' ;
2026-09-21 16:58:49 +03:30
const envLines = memberships . map (( row ) => {
2026-09-21 19:24:39 +03:30
const current = row . organizationId === orgId ? ' [current]' : '' ;
2026-09-21 16:58:49 +03:30
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 ;
2026-09-21 19:24:39 +03:30
const peopleTodayNames = peopleToday
. map (( p ) => ` ${ p . firstName } ${ p . lastName } ` . trim ())
. filter ( Boolean );
2026-09-03 14:20:55 +03:30
const lines : string [] = [
2026-10-13 18:04:53 +03:30
`Today (Asia/Tehran, Jalali): ${ formatTehranJalali ( now ) } | gregorian_key= ${ dayKey } ` ,
2026-09-21 19:24:39 +03:30
`Periods: yesterday= ${ periods . yesterday . key } | this_week= ${ periods . week . key } (Sat– Fri) | this_month= ${ periods . month . key } (Jalali)` ,
2026-09-21 16:58:49 +03:30
'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' } ` ,
2026-09-21 19:24:39 +03:30
`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 } ` ,
2026-10-13 14:45:29 +03:30
...( await this . filterFactLines ( actor )),
2026-09-21 19:24:39 +03:30
'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 ),
2026-09-03 14:20:55 +03:30
];
2026-09-21 16:58:49 +03:30
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 (
2026-09-21 19:24:39 +03:30
personId
? `Focus personId: ${ personId } `
: 'Scope: current user records in the current environment' ,
2026-09-21 16:58:49 +03:30
);
2026-09-03 14:20:55 +03:30
if ( personId ) {
const person = await this . prisma . person . findFirst ({
2026-09-21 19:24:39 +03:30
where : { id : personId , organizationId : orgId },
2026-09-03 14:20:55 +03:30
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 ?? '—' } ` ,
);
}
2026-09-21 19:24:39 +03:30
} 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 (
2026-10-13 18:04:53 +03:30
`Person ${ p . id } ${ label } created_jalali= ${ formatTehranJalali ( p . createdAt ) } phone= ${ p . mobile || p . phone || '—' } email= ${ p . email ?? '—' } ` ,
2026-09-21 19:24:39 +03:30
);
}
2026-09-03 14:20:55 +03:30
}
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 (
2026-10-13 18:04:53 +03:30
`Note ${ note . id } (jalali= ${ formatTehranJalali ( note . createdAt ) } ) ${ who ? ` [ ${ who } ]` : '' } : ${ clip ( note . title ? ` ${ note . title } — ${ note . body } ` : note . body ) } ` ,
2026-09-03 14:20:55 +03:30
);
}
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 (
2026-10-13 18:04:53 +03:30
`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 ) } ` : '' } ` ,
2026-09-03 14:20:55 +03:30
);
}
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 } ` : '' } ` ,
);
}
2026-09-21 19:24:39 +03:30
return { context : lines.join ( '\n' ). slice ( 0 , 14000 ), catalog };
2026-09-03 14:20:55 +03:30
}
2026-09-23 15:49:44 +03:30
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 ,
2026-10-13 18:04:53 +03:30
summary : rewriteGregorianDates (( raw . summary ?? '' ). trim ()) || `ساخت مشتری ${ person . firstName } ${ person . lastName } ` . trim (),
2026-09-23 15:49:44 +03:30
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 ,
2026-10-13 18:04:53 +03:30
summary : rewriteGregorianDates (( raw . summary ?? '' ). trim ()) || `ثبت فعالیت « ${ activity . title } »` ,
2026-09-23 15:49:44 +03:30
missing ,
canConfirm : missing.length === 0 ,
person : null ,
activity ,
};
}
return {
intent : 'none' ,
summary :
2026-10-13 18:04:53 +03:30
rewriteGregorianDates (( raw . summary ?? '' ). trim ()) ||
2026-09-23 15:49:44 +03:30
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام میشود.' ,
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 ,
};
2026-09-03 14:20:55 +03:30
}