678 lines
22 KiB
TypeScript
678 lines
22 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
import type {
|
||
AskMentoResponse,
|
||
AskMentoSourceDto,
|
||
PersonAiSummaryDto,
|
||
} from '@mento/shared';
|
||
import { PrismaService } from '../prisma/prisma.service';
|
||
import {
|
||
type Actor,
|
||
assigneeWhere,
|
||
noteAccessWhere,
|
||
ownerWhere,
|
||
ticketAccessWhere,
|
||
} from '../common/record-scope';
|
||
import {
|
||
BILLING_PLAN_LABELS,
|
||
BUSINESS_FIELD_LABELS,
|
||
USER_ROLE_LABELS,
|
||
brand,
|
||
brandSuggestionFa,
|
||
isBusinessField,
|
||
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 '';
|
||
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 { 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(
|
||
private readonly prisma: PrismaService,
|
||
private readonly ai: AiService,
|
||
) {}
|
||
|
||
async ask(
|
||
actor: Actor,
|
||
question: string,
|
||
personId?: string,
|
||
): Promise<AskMentoResponse> {
|
||
const { context, catalog } = await this.buildContext(actor, personId);
|
||
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 (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/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}`,
|
||
);
|
||
|
||
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 = 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.`,
|
||
`Customer briefing context:\n${context}`,
|
||
);
|
||
|
||
const text = String(raw.text ?? '').trim().slice(0, 700);
|
||
const cta = String(raw.cta ?? '').trim().slice(0, 120);
|
||
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
|
||
return {
|
||
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
|
||
cta: cta || brandSuggestionFa('امروز با مشتری تماس بگیر'),
|
||
ctaKind,
|
||
};
|
||
}
|
||
|
||
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);
|
||
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,
|
||
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),
|
||
...(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): ${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}`,
|
||
`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(
|
||
`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=${p.createdAt.toISOString()} 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} (${note.createdAt.toISOString()})${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=${activity.dueAt?.toISOString() ?? '—'} 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 };
|
||
}
|
||
}
|