diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 5486792..9353fc4 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -11,7 +11,16 @@ import { noteAccessWhere, ownerWhere, } from '../common/record-scope'; -import { BUSINESS_FIELD_LABELS, brand, brandSuggestionFa, isBusinessField } from '@mento/shared'; +import { + BILLING_PLAN_LABELS, + BUSINESS_FIELD_LABELS, + USER_ROLE_LABELS, + brand, + brandSuggestionFa, + isBusinessField, + type BillingPlanCode, + type UserRole, +} from '@mento/shared'; import { AiService } from './ai.service'; type CatalogItem = AskMentoSourceDto; @@ -22,6 +31,14 @@ function clip(value: string | null | undefined, max = 280) { 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; +} + @Injectable() export class AiAskService { constructor( @@ -39,12 +56,14 @@ export class AiAskService { answer?: string; sources?: Array<{ type?: string; id?: string; label?: string }>; }>( - `You are ${brand.name}, a Persian CRM sales assistant. -Answer ONLY from the CRM context. If the context is insufficient, say so in Persian. + `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. 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. -Write the answer in Persian, concise, with dates when present.`, +Use at most 4 sources, and only ids that appear in the context. Workspace facts do not need sources. +Write the answer in Persian, concise, with exact numbers and names when present.`, `Question:\n${question.trim()}\n\nCRM context:\n${context}`, ); @@ -102,11 +121,108 @@ Rules: if (!person) throw new NotFoundException('شخص پیدا نشد'); } - const [org, notes, activities, deals, memories] = await Promise.all([ + 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 dealScope = ownerWhere(actor); + const activityScope = assigneeWhere(actor); + const personScope = ownerWhere(actor); + + const [ + org, + user, + memberships, + ownerBilling, + peopleCount, + openDealCount, + staleDealCount, + overdueActivityCount, + todayActivityCount, + teamCount, + notes, + activities, + deals, + memories, + ] = await Promise.all([ this.prisma.organization.findUnique({ where: { id: actor.organizationId }, 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: actor.organizationId, + role: 'owner', + isActive: true, + }, + }, + }, + }, + select: { + planCode: true, + status: true, + expiresAt: true, + maxOrganizations: true, + }, + }), + this.prisma.person.count({ + where: { organizationId: actor.organizationId, ...personScope }, + }), + this.prisma.deal.count({ + where: { + organizationId: actor.organizationId, + status: 'open', + ...dealScope, + }, + }), + this.prisma.deal.count({ + where: { + organizationId: actor.organizationId, + status: 'open', + ...dealScope, + OR: [{ isStale: true }, { isRotten: true }], + }, + }), + this.prisma.activity.count({ + where: { + organizationId: actor.organizationId, + ...activityScope, + status: 'open', + dueAt: { lt: now }, + }, + }), + this.prisma.activity.count({ + where: { + organizationId: actor.organizationId, + ...activityScope, + status: 'open', + dueAt: { gte: todayStart, lte: todayEnd }, + }, + }), + this.prisma.membership.count({ + where: { organizationId: actor.organizationId, isActive: true }, + }), this.prisma.note.findMany({ where: { organizationId: actor.organizationId, @@ -176,11 +292,35 @@ Rules: org?.businessField && isBusinessField(org.businessField) ? BUSINESS_FIELD_LABELS[org.businessField] : 'نامشخص'; + const envLines = memberships.map((row) => { + const current = row.organizationId === actor.organizationId ? ' [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 lines: string[] = [ - `Today: ${new Date().toISOString()}`, - `Organization: ${org?.name ?? '—'} | business field: ${field}`, - personId ? `Focus personId: ${personId}` : 'Scope: current user records', + `Today: ${now.toISOString()}`, + '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}`, ]; + 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({ diff --git a/apps/web/src/components/dashboard/HomeAiAssistant.vue b/apps/web/src/components/dashboard/HomeAiAssistant.vue index eba3ce2..fd47eb4 100644 --- a/apps/web/src/components/dashboard/HomeAiAssistant.vue +++ b/apps/web/src/components/dashboard/HomeAiAssistant.vue @@ -37,7 +37,7 @@ function pick(label: string) {