diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 9353fc4..1bf6828 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -10,6 +10,7 @@ import { assigneeWhere, noteAccessWhere, ownerWhere, + ticketAccessWhere, } from '../common/record-scope'; import { BILLING_PLAN_LABELS, @@ -21,10 +22,20 @@ import { type BillingPlanCode, type UserRole, } from '@mento/shared'; +import { startOfJalaliMonth, toJalali } from '../common/jalali'; import { AiService } from './ai.service'; type CatalogItem = AskMentoSourceDto; +type PeriodRange = { key: string; label: string; start: Date; end: Date }; + +type PeriodCounts = { + today: number; + yesterday: number; + week: number; + month: number; +}; + function clip(value: string | null | undefined, max = 280) { const text = (value ?? '').replace(/\s+/g, ' ').trim(); if (!text) return ''; @@ -39,6 +50,75 @@ 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 = { + 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}`; +} + @Injectable() export class AiAskService { constructor( @@ -57,12 +137,14 @@ export class AiAskService { 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 (listed under Workspace facts). -Workspace facts are complete and authoritative. Use them for questions about how many environments the user has, the current environment, role, subscription, team size, and record counts. -Answer ONLY from the CRM context. Never say a workspace fact is unavailable if it appears in the context. +"محیط" means a workspace/organization the user belongs to. +"مشتری" / "مشتری جدید" means a Person (contact) record. +Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month). +Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets. +Answer ONLY from the CRM context. Never say a listed count is unavailable. 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 facts do not need sources. +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}`, ); @@ -108,6 +190,19 @@ Rules: }; } + private async countInPeriods( + count: (start: Date, end: Date) => Promise, + periods: ReturnType, + ): Promise { + 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({ @@ -121,33 +216,52 @@ Rules: if (!person) throw new NotFoundException('شخص پیدا نشد'); } - const now = new Date(); - const todayStart = new Date(now); - todayStart.setHours(0, 0, 0, 0); - const todayEnd = new Date(now); - todayEnd.setHours(23, 59, 59, 999); + const periods = tehranPeriods(); + const { now, dayKey } = periods; + const orgId = actor.organizationId; const dealScope = ownerWhere(actor); const activityScope = assigneeWhere(actor); const personScope = ownerWhere(actor); + const companyScope = ownerWhere(actor); + const invoiceScope = ownerWhere(actor); + const quoteScope = ownerWhere(actor); + const ticketScope = ticketAccessWhere(actor); + + const createdBetween = + (start: Date, end: Date) => ({ createdAt: { gte: start, lte: end } }); const [ org, user, memberships, ownerBilling, - peopleCount, + teamCount, + peopleTotal, + companiesTotal, + productsTotal, openDealCount, staleDealCount, overdueActivityCount, - todayActivityCount, - teamCount, + 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: actor.organizationId }, + where: { id: orgId }, select: { name: true, businessField: true }, }), this.prisma.user.findUnique({ @@ -172,7 +286,7 @@ Rules: owner: { memberships: { some: { - organizationId: actor.organizationId, + organizationId: orgId, role: 'owner', isActive: true, }, @@ -186,19 +300,24 @@ Rules: maxOrganizations: true, }, }), + this.prisma.membership.count({ + where: { organizationId: orgId, isActive: true }, + }), this.prisma.person.count({ - where: { organizationId: actor.organizationId, ...personScope }, + 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: actor.organizationId, - status: 'open', - ...dealScope, - }, - }), - this.prisma.deal.count({ - where: { - organizationId: actor.organizationId, + organizationId: orgId, status: 'open', ...dealScope, OR: [{ isStale: true }, { isRotten: true }], @@ -206,7 +325,7 @@ Rules: }), this.prisma.activity.count({ where: { - organizationId: actor.organizationId, + organizationId: orgId, ...activityScope, status: 'open', dueAt: { lt: now }, @@ -214,18 +333,157 @@ Rules: }), this.prisma.activity.count({ where: { - organizationId: actor.organizationId, + organizationId: orgId, ...activityScope, status: 'open', - dueAt: { gte: todayStart, lte: todayEnd }, + dueAt: { gte: periods.today.start, lte: periods.today.end }, }, }), - this.prisma.membership.count({ - where: { organizationId: actor.organizationId, isActive: true }, + 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: actor.organizationId, + organizationId: orgId, ...noteAccessWhere(actor), ...(personId ? { personId } : {}), }, @@ -241,7 +499,7 @@ Rules: }), this.prisma.activity.findMany({ where: { - organizationId: actor.organizationId, + organizationId: orgId, ...assigneeWhere(actor), ...(personId ? { personId } : {}), }, @@ -259,7 +517,7 @@ Rules: }), this.prisma.deal.findMany({ where: { - organizationId: actor.organizationId, + organizationId: orgId, ...ownerWhere(actor), ...(personId ? { personId } : { status: 'open' }), }, @@ -279,7 +537,7 @@ Rules: }), personId ? this.prisma.memoryItem.findMany({ - where: { organizationId: actor.organizationId, personId }, + where: { organizationId: orgId, personId }, select: { id: true, content: true, updatedAt: true }, orderBy: { updatedAt: 'desc' }, take: 8, @@ -293,7 +551,7 @@ Rules: ? BUSINESS_FIELD_LABELS[org.businessField] : 'نامشخص'; const envLines = memberships.map((row) => { - const current = row.organizationId === actor.organizationId ? ' [current]' : ''; + const current = row.organizationId === orgId ? ' [current]' : ''; return `${row.organization.name}${current} (role=${roleLabel(row.role)})`; }); const billingDays = ownerBilling @@ -305,13 +563,33 @@ Rules: ), ) : null; + const peopleTodayNames = peopleToday + .map((p) => `${p.firstName} ${p.lastName}`.trim()) + .filter(Boolean); + const lines: string[] = [ - `Today: ${now.toISOString()}`, + `Today (Asia/Tehran): ${dayKey} | now=${now.toISOString()}`, + `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} | people: ${peopleCount} | open deals: ${openDealCount} | at-risk deals: ${staleDealCount} | overdue activities: ${overdueActivityCount} | activities due today: ${todayActivityCount}`, + `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}`, + '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( @@ -319,12 +597,14 @@ Rules: ); } lines.push( - personId ? `Focus personId: ${personId}` : 'Scope: current user records in the current environment', + 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: actor.organizationId }, + where: { id: personId, organizationId: orgId }, include: { company: { select: { name: true } } }, }); if (person) { @@ -337,6 +617,17 @@ Rules: `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(); + 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=${p.createdAt.toISOString()} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`, + ); + } } for (const item of memories) { @@ -381,6 +672,6 @@ Rules: ); } - return { context: lines.join('\n').slice(0, 12000), catalog }; + return { context: lines.join('\n').slice(0, 14000), catalog }; } }