propstack-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +55 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/propstack-client.d.ts +24 -0
  8. package/dist/propstack-client.d.ts.map +1 -0
  9. package/dist/propstack-client.js +99 -0
  10. package/dist/propstack-client.js.map +1 -0
  11. package/dist/tools/activities.d.ts +4 -0
  12. package/dist/tools/activities.d.ts.map +1 -0
  13. package/dist/tools/activities.js +177 -0
  14. package/dist/tools/activities.js.map +1 -0
  15. package/dist/tools/admin.d.ts +4 -0
  16. package/dist/tools/admin.d.ts.map +1 -0
  17. package/dist/tools/admin.js +148 -0
  18. package/dist/tools/admin.js.map +1 -0
  19. package/dist/tools/composites.d.ts +4 -0
  20. package/dist/tools/composites.d.ts.map +1 -0
  21. package/dist/tools/composites.js +807 -0
  22. package/dist/tools/composites.js.map +1 -0
  23. package/dist/tools/contacts.d.ts +4 -0
  24. package/dist/tools/contacts.d.ts.map +1 -0
  25. package/dist/tools/contacts.js +386 -0
  26. package/dist/tools/contacts.js.map +1 -0
  27. package/dist/tools/deals.d.ts +4 -0
  28. package/dist/tools/deals.d.ts.map +1 -0
  29. package/dist/tools/deals.js +230 -0
  30. package/dist/tools/deals.js.map +1 -0
  31. package/dist/tools/documents.d.ts +4 -0
  32. package/dist/tools/documents.d.ts.map +1 -0
  33. package/dist/tools/documents.js +109 -0
  34. package/dist/tools/documents.js.map +1 -0
  35. package/dist/tools/emails.d.ts +4 -0
  36. package/dist/tools/emails.d.ts.map +1 -0
  37. package/dist/tools/emails.js +111 -0
  38. package/dist/tools/emails.js.map +1 -0
  39. package/dist/tools/helpers.d.ts +39 -0
  40. package/dist/tools/helpers.d.ts.map +1 -0
  41. package/dist/tools/helpers.js +160 -0
  42. package/dist/tools/helpers.js.map +1 -0
  43. package/dist/tools/lookups.d.ts +4 -0
  44. package/dist/tools/lookups.d.ts.map +1 -0
  45. package/dist/tools/lookups.js +333 -0
  46. package/dist/tools/lookups.js.map +1 -0
  47. package/dist/tools/projects.d.ts +4 -0
  48. package/dist/tools/projects.d.ts.map +1 -0
  49. package/dist/tools/projects.js +104 -0
  50. package/dist/tools/projects.js.map +1 -0
  51. package/dist/tools/properties.d.ts +4 -0
  52. package/dist/tools/properties.d.ts.map +1 -0
  53. package/dist/tools/properties.js +397 -0
  54. package/dist/tools/properties.js.map +1 -0
  55. package/dist/tools/relationships.d.ts +4 -0
  56. package/dist/tools/relationships.d.ts.map +1 -0
  57. package/dist/tools/relationships.js +55 -0
  58. package/dist/tools/relationships.js.map +1 -0
  59. package/dist/tools/search-profiles.d.ts +4 -0
  60. package/dist/tools/search-profiles.d.ts.map +1 -0
  61. package/dist/tools/search-profiles.js +345 -0
  62. package/dist/tools/search-profiles.js.map +1 -0
  63. package/dist/tools/tasks.d.ts +4 -0
  64. package/dist/tools/tasks.d.ts.map +1 -0
  65. package/dist/tools/tasks.js +251 -0
  66. package/dist/tools/tasks.js.map +1 -0
  67. package/dist/types/propstack.d.ts +444 -0
  68. package/dist/types/propstack.d.ts.map +1 -0
  69. package/dist/types/propstack.js +3 -0
  70. package/dist/types/propstack.js.map +1 -0
  71. package/package.json +54 -0
@@ -0,0 +1,807 @@
1
+ import { z } from "zod";
2
+ import { textResult, errorResult, fmt, fmtPrice, fmtArea, formatError, stripUndefined, unwrapNumber } from "./helpers.js";
3
+ function daysBetween(from, to) {
4
+ return Math.floor((to.getTime() - new Date(from).getTime()) / (1000 * 60 * 60 * 24));
5
+ }
6
+ function contactName(c) {
7
+ return fmt(c.name) !== "none" ? fmt(c.name) : ([fmt(c.first_name, ""), fmt(c.last_name, "")].filter(Boolean).join(" ") || "Unknown");
8
+ }
9
+ // ── Tool registration ────────────────────────────────────────────────
10
+ export function registerCompositeTools(server, client) {
11
+ // ── full_contact_360 ────────────────────────────────────────────
12
+ server.tool("full_contact_360", `Get a complete 360° view of a contact — everything you need before calling a client.
13
+
14
+ Combines 4 API calls in parallel:
15
+ - Full contact details with children, documents, relationships, owned properties
16
+ - Search profiles (what they're looking for)
17
+ - Active deals (which properties they're linked to)
18
+ - Recent activity (last 10 interactions)
19
+
20
+ Returns a complete contact dossier in one request. Use this when you
21
+ need the full picture: "Tell me everything about Herr Weber."`, {
22
+ contact_id: z.number()
23
+ .describe("Contact ID to get 360° view for"),
24
+ }, async (args) => {
25
+ try {
26
+ const [contactRes, searchProfilesRes, dealsRes, activitiesRes] = await Promise.allSettled([
27
+ client.get(`/contacts/${args.contact_id}`, { params: { include: "children,documents,relationships,owned_properties", expand: "true" } }),
28
+ client.get("/saved_queries", { params: { client: args.contact_id } }),
29
+ client.get("/client_properties", { params: { client_id: args.contact_id, include: "client,property" } }),
30
+ client.get("/activities", { params: { client_id: args.contact_id, per: 10 } }),
31
+ ]);
32
+ // Contact is essential — if it fails, return error
33
+ if (contactRes.status === "rejected") {
34
+ return errorResult("Contact 360", contactRes.reason);
35
+ }
36
+ const contact = contactRes.value;
37
+ const sections = [];
38
+ const warnings = [];
39
+ // ── Personal info
40
+ const name = contactName(contact);
41
+ const personalLines = [
42
+ `# ${name} (ID: ${contact.id})`,
43
+ "",
44
+ `Email: ${fmt(contact.email)}`,
45
+ `Phone: ${fmt(contact.phone ?? contact.home_cell)}`,
46
+ contact.company ? `Company: ${contact.company}` : null,
47
+ contact.position ? `Position: ${contact.position}` : null,
48
+ `Status: ${fmt(contact.client_status?.name)}`,
49
+ `Broker: ${fmt(contact.broker?.name, "unassigned")}`,
50
+ `Rating: ${"★".repeat(Math.max(0, Math.min(3, contact.rating ?? 0)))}${"☆".repeat(3 - Math.max(0, Math.min(3, contact.rating ?? 0)))}`,
51
+ `GDPR: ${["Keine Angabe", "Ignoriert", "Zugestimmt", "Widerrufen"][contact.gdpr_status ?? 0] ?? "unknown"}`,
52
+ `Last contact: ${fmt(contact.last_contact_at_formatted, "never")}`,
53
+ contact.warning_notice ? `Warning: ${contact.warning_notice}` : null,
54
+ contact.description ? `Notes: ${contact.description}` : null,
55
+ ];
56
+ // Address
57
+ const homeAddr = [contact.home_street, contact.home_house_number].filter(Boolean).join(" ");
58
+ const homeCity = [contact.home_zip_code, contact.home_city].filter(Boolean).join(" ");
59
+ const homeFull = [homeAddr, homeCity].filter(Boolean).join(", ");
60
+ if (homeFull)
61
+ personalLines.push(`Home: ${homeFull}`);
62
+ // Children
63
+ if (contact.children?.length) {
64
+ personalLines.push(`Sub-contacts: ${contact.children.map((c) => `${contactName(c)} (ID: ${c.id})`).join(", ")}`);
65
+ }
66
+ // Owned properties
67
+ if (contact.owned_properties?.length) {
68
+ personalLines.push(`Owned properties: ${contact.owned_properties.map((p) => `${fmt(p.title, "Untitled")} (ID: ${p.id})`).join(", ")}`);
69
+ }
70
+ // Documents
71
+ if (contact.documents?.length) {
72
+ personalLines.push(`Documents: ${contact.documents.length} file(s)`);
73
+ }
74
+ // Custom fields
75
+ if (contact.custom_fields && Object.keys(contact.custom_fields).length > 0) {
76
+ personalLines.push("");
77
+ personalLines.push("**Custom Fields:**");
78
+ for (const [key, val] of Object.entries(contact.custom_fields)) {
79
+ const s = fmt(val, "");
80
+ if (s)
81
+ personalLines.push(` ${key}: ${s}`);
82
+ }
83
+ }
84
+ sections.push(personalLines.filter((l) => l !== null).join("\n"));
85
+ // ── Search profiles
86
+ if (searchProfilesRes.status === "fulfilled") {
87
+ const profiles = searchProfilesRes.value.data ?? [];
88
+ if (profiles.length > 0) {
89
+ const spLines = [`## Search Profiles (${profiles.length})`, ""];
90
+ for (const sp of profiles) {
91
+ const parts = [`**Profile #${sp.id}** (${sp.active ? "active" : "inactive"})`];
92
+ if (sp.marketing_type)
93
+ parts.push(` Type: ${sp.marketing_type}`);
94
+ if (sp.cities?.length)
95
+ parts.push(` Cities: ${sp.cities.join(", ")}`);
96
+ if (sp.rs_types?.length)
97
+ parts.push(` Property types: ${sp.rs_types.join(", ")}`);
98
+ if (sp.price !== null || sp.price_to !== null) {
99
+ parts.push(` Price: ${fmtPrice(sp.price)} – ${fmtPrice(sp.price_to)}`);
100
+ }
101
+ if (sp.base_rent !== null || sp.base_rent_to !== null) {
102
+ parts.push(` Rent: ${fmtPrice(sp.base_rent)} – ${fmtPrice(sp.base_rent_to)}`);
103
+ }
104
+ if (sp.number_of_rooms !== null || sp.number_of_rooms_to !== null) {
105
+ parts.push(` Rooms: ${fmt(sp.number_of_rooms)} – ${fmt(sp.number_of_rooms_to)}`);
106
+ }
107
+ if (sp.living_space !== null || sp.living_space_to !== null) {
108
+ parts.push(` Space: ${fmt(sp.living_space)} – ${fmt(sp.living_space_to)} m²`);
109
+ }
110
+ if (sp.note)
111
+ parts.push(` Note: ${sp.note}`);
112
+ spLines.push(parts.join("\n"));
113
+ }
114
+ sections.push(spLines.join("\n"));
115
+ }
116
+ else {
117
+ sections.push("## Search Profiles\nNone — contact has no active search criteria.");
118
+ }
119
+ }
120
+ else {
121
+ warnings.push("Search profiles");
122
+ sections.push(`## Search Profiles\nFailed to load: ${formatError(searchProfilesRes.reason)}`);
123
+ }
124
+ // ── Deals
125
+ if (dealsRes.status === "fulfilled") {
126
+ const dealList = dealsRes.value.data ?? [];
127
+ if (dealList.length > 0) {
128
+ const dealLines = [`## Deals (${dealList.length})`, ""];
129
+ for (const d of dealList) {
130
+ const propTitle = d.property ? fmt(d.property.title, "Untitled") : `Property #${d.property_id}`;
131
+ const parts = [
132
+ `**Deal #${d.id}**: ${propTitle}`,
133
+ ` Stage: ${fmt(d.deal_stage_id)} | Category: ${fmt(d.category)}`,
134
+ ];
135
+ if (d.sold_price)
136
+ parts.push(` Price: ${fmtPrice(d.sold_price)}`);
137
+ if (d.note)
138
+ parts.push(` Note: ${d.note}`);
139
+ parts.push(` Created: ${fmt(d.created_at)}`);
140
+ dealLines.push(parts.join("\n"));
141
+ }
142
+ sections.push(dealLines.join("\n"));
143
+ }
144
+ else {
145
+ sections.push("## Deals\nNo deals — contact is not linked to any properties in the pipeline.");
146
+ }
147
+ }
148
+ else {
149
+ warnings.push("Deals");
150
+ sections.push(`## Deals\nFailed to load: ${formatError(dealsRes.reason)}`);
151
+ }
152
+ // ── Recent activity
153
+ if (activitiesRes.status === "fulfilled") {
154
+ const activityList = activitiesRes.value.data ?? [];
155
+ if (activityList.length > 0) {
156
+ const actLines = [`## Recent Activity (last ${activityList.length})`, ""];
157
+ for (const a of activityList) {
158
+ actLines.push(`- **${fmt(a.type)}** ${fmt(a.title, "")} — ${fmt(a.created_at)}`);
159
+ }
160
+ sections.push(actLines.join("\n"));
161
+ }
162
+ else {
163
+ sections.push("## Recent Activity\nNo recorded activity.");
164
+ }
165
+ }
166
+ else {
167
+ warnings.push("Activities");
168
+ sections.push(`## Recent Activity\nFailed to load: ${formatError(activitiesRes.reason)}`);
169
+ }
170
+ if (warnings.length > 0) {
171
+ sections.push(`\n**Note:** Some sections failed to load (${warnings.join(", ")}). The rest of the data is shown above.`);
172
+ }
173
+ return textResult(sections.join("\n\n---\n\n"));
174
+ }
175
+ catch (err) {
176
+ return errorResult("Contact 360", err);
177
+ }
178
+ });
179
+ // ── property_performance_report ─────────────────────────────────
180
+ server.tool("property_performance_report", `Performance report for a property — days on market, inquiry count,
181
+ pipeline breakdown, and activity summary.
182
+
183
+ Combines 3 API calls in parallel:
184
+ - Full property details (with custom fields)
185
+ - All deals/inquiries for this property
186
+ - Activity feed (last 50 interactions)
187
+
188
+ Calculates: days on market, total inquiries, deals by stage/category,
189
+ and recent activity breakdown by type.
190
+
191
+ Use when asked: "How is the Friedrichstr property doing?"`, {
192
+ property_id: z.number()
193
+ .describe("Property ID to generate report for"),
194
+ }, async (args) => {
195
+ try {
196
+ const [propertyRes, dealsRes, activitiesRes] = await Promise.allSettled([
197
+ client.get(`/units/${args.property_id}`, { params: { new: 1, expand: 1 } }),
198
+ client.get("/client_properties", { params: { property_id: args.property_id, include: "client" } }),
199
+ client.get("/activities", { params: { property_id: args.property_id, per: 50 } }),
200
+ ]);
201
+ // Property is essential
202
+ if (propertyRes.status === "rejected") {
203
+ return errorResult("Property report", propertyRes.reason);
204
+ }
205
+ const property = propertyRes.value;
206
+ const sections = [];
207
+ const warnings = [];
208
+ const now = new Date();
209
+ // ── Property summary
210
+ const title = fmt(property.title, "Untitled");
211
+ const addr = [property.street, property.house_number].filter(Boolean).join(" ");
212
+ const city = [property.zip_code, property.city].filter(Boolean).join(" ");
213
+ const fullAddr = [addr, city].filter(Boolean).join(", ");
214
+ const dom = property.created_at ? daysBetween(property.created_at, now) : null;
215
+ const propLines = [
216
+ `# Property Report: ${title} (ID: ${property.id})`,
217
+ "",
218
+ `Address: ${fullAddr || "none"}`,
219
+ `Type: ${fmt(property.marketing_type)} / ${fmt(property.rs_type)}`,
220
+ property.price ? `Price: ${fmtPrice(property.price)}` : null,
221
+ property.base_rent ? `Base rent: ${fmtPrice(property.base_rent)}` : null,
222
+ `Rooms: ${fmt(property.number_of_rooms)} | Space: ${fmtArea(property.living_space)}`,
223
+ `Status: ${fmt(property.property_status?.name)}`,
224
+ `Broker: ${fmt(property.broker?.name, "unassigned")}`,
225
+ dom !== null ? `Days on market: ${dom}` : null,
226
+ `Created: ${fmt(property.created_at)}`,
227
+ ];
228
+ sections.push(propLines.filter((l) => l !== null).join("\n"));
229
+ // ── Deal/inquiry analysis
230
+ if (dealsRes.status === "fulfilled") {
231
+ const dealList = dealsRes.value.data ?? [];
232
+ const totalInquiries = dealsRes.value.meta?.total_count ?? dealList.length;
233
+ const byCategory = {};
234
+ const byStage = {};
235
+ let totalValue = 0;
236
+ for (const d of dealList) {
237
+ const cat = d.category ?? "unknown";
238
+ byCategory[cat] = (byCategory[cat] ?? 0) + 1;
239
+ const stage = String(d.deal_stage_id ?? "unknown");
240
+ byStage[stage] = (byStage[stage] ?? 0) + 1;
241
+ if (d.sold_price)
242
+ totalValue += d.sold_price;
243
+ }
244
+ const dealLines = [
245
+ "## Pipeline Analysis",
246
+ "",
247
+ `Total inquiries: ${totalInquiries}`,
248
+ ];
249
+ if (Object.keys(byCategory).length > 0) {
250
+ dealLines.push("");
251
+ dealLines.push("**By category:**");
252
+ for (const [cat, count] of Object.entries(byCategory)) {
253
+ dealLines.push(` ${cat}: ${count}`);
254
+ }
255
+ }
256
+ if (Object.keys(byStage).length > 0) {
257
+ dealLines.push("");
258
+ dealLines.push("**By stage ID:**");
259
+ for (const [stage, count] of Object.entries(byStage)) {
260
+ dealLines.push(` Stage ${stage}: ${count}`);
261
+ }
262
+ }
263
+ if (totalValue > 0) {
264
+ dealLines.push("");
265
+ dealLines.push(`Total deal value: ${fmtPrice(totalValue)}`);
266
+ }
267
+ if (dealList.length > 0) {
268
+ dealLines.push("");
269
+ dealLines.push("**Interested contacts:**");
270
+ for (const d of dealList.slice(0, 10)) {
271
+ const cName = d.client ? contactName(d.client) : `Contact #${d.client_id}`;
272
+ dealLines.push(` • ${cName} — Stage: ${fmt(d.deal_stage_id)}, Category: ${fmt(d.category)}`);
273
+ }
274
+ }
275
+ sections.push(dealLines.join("\n"));
276
+ }
277
+ else {
278
+ warnings.push("Deals");
279
+ sections.push(`## Pipeline Analysis\nFailed to load: ${formatError(dealsRes.reason)}`);
280
+ }
281
+ // ── Activity breakdown
282
+ if (activitiesRes.status === "fulfilled") {
283
+ const activityList = activitiesRes.value.data ?? [];
284
+ const totalActivities = activitiesRes.value.meta?.total_count ?? activityList.length;
285
+ const byType = {};
286
+ for (const a of activityList) {
287
+ const t = a.type ?? "unknown";
288
+ byType[t] = (byType[t] ?? 0) + 1;
289
+ }
290
+ const actLines = [
291
+ "## Activity Summary",
292
+ "",
293
+ `Total activities: ${totalActivities} (showing last ${activityList.length})`,
294
+ ];
295
+ if (Object.keys(byType).length > 0) {
296
+ actLines.push("");
297
+ actLines.push("**By type:**");
298
+ for (const [type, count] of Object.entries(byType)) {
299
+ actLines.push(` ${type}: ${count}`);
300
+ }
301
+ }
302
+ if (activityList.length > 0) {
303
+ actLines.push("");
304
+ actLines.push("**Recent:**");
305
+ for (const a of activityList.slice(0, 5)) {
306
+ actLines.push(` - ${fmt(a.type)} — ${fmt(a.title, "")} — ${fmt(a.created_at)}`);
307
+ }
308
+ }
309
+ sections.push(actLines.join("\n"));
310
+ }
311
+ else {
312
+ warnings.push("Activities");
313
+ sections.push(`## Activity Summary\nFailed to load: ${formatError(activitiesRes.reason)}`);
314
+ }
315
+ if (warnings.length > 0) {
316
+ sections.push(`\n**Note:** Some sections failed to load (${warnings.join(", ")}). The rest of the data is shown above.`);
317
+ }
318
+ return textResult(sections.join("\n\n---\n\n"));
319
+ }
320
+ catch (err) {
321
+ return errorResult("Property report", err);
322
+ }
323
+ });
324
+ // ── pipeline_summary ────────────────────────────────────────────
325
+ server.tool("pipeline_summary", `Pipeline overview — deals per stage, total values, and stale deals
326
+ needing attention.
327
+
328
+ Fetches all deal pipelines and deals, then aggregates:
329
+ - Deal count per stage
330
+ - Total value per stage (from deal price or property price)
331
+ - Stale deals: deals with no update in 14+ days
332
+
333
+ Filter by pipeline_id and/or broker_id. Use when asked:
334
+ "How's the pipeline looking?" or "Give me a sales overview."`, {
335
+ pipeline_id: z.number().optional()
336
+ .describe("Filter by specific pipeline ID"),
337
+ broker_id: z.number().optional()
338
+ .describe("Filter by broker ID"),
339
+ }, async (args) => {
340
+ try {
341
+ const pipelinesRes = await client.get("/deal_pipelines").then((v) => ({ status: "fulfilled", value: v }), (e) => ({ status: "rejected", reason: e }));
342
+ if (pipelinesRes.status === "rejected") {
343
+ return errorResult("Pipeline summary", pipelinesRes.reason);
344
+ }
345
+ const pipelines = pipelinesRes.value;
346
+ const dealParams = {
347
+ include: "client,property",
348
+ per_page: 100,
349
+ };
350
+ if (args.pipeline_id)
351
+ dealParams["deal_pipeline_id"] = args.pipeline_id;
352
+ if (args.broker_id)
353
+ dealParams["broker_id"] = args.broker_id;
354
+ const allDeals = [];
355
+ let page = 1;
356
+ let totalCount;
357
+ const maxDeals = 2000;
358
+ do {
359
+ const res = await client.get("/client_properties", { params: { ...dealParams, page } });
360
+ if (res.data?.length)
361
+ allDeals.push(...res.data);
362
+ totalCount = res.meta?.total_count;
363
+ page++;
364
+ } while (allDeals.length < (totalCount ?? 0) &&
365
+ allDeals.length < maxDeals);
366
+ const warnings = [];
367
+ if (totalCount !== undefined && allDeals.length >= maxDeals && totalCount > maxDeals) {
368
+ warnings.push(`Summary capped at ${maxDeals} deals (${totalCount} total).`);
369
+ }
370
+ const deals = { data: allDeals, meta: totalCount !== undefined ? { total_count: totalCount } : undefined };
371
+ const dealList = deals.data ?? [];
372
+ const now = new Date();
373
+ const staleThresholdMs = 14 * 24 * 60 * 60 * 1000;
374
+ // Build stage name lookup
375
+ const stageNames = {};
376
+ const pipelineNames = {};
377
+ for (const p of pipelines) {
378
+ pipelineNames[p.id] = p.name ?? "Unnamed";
379
+ if (p.deal_stages) {
380
+ for (const s of p.deal_stages) {
381
+ stageNames[s.id] = s.name ?? "Unnamed";
382
+ }
383
+ }
384
+ }
385
+ // Aggregate
386
+ const stageStats = {};
387
+ const staleDeals = [];
388
+ let totalDeals = 0;
389
+ let totalValue = 0;
390
+ for (const d of dealList) {
391
+ totalDeals++;
392
+ const stageName = d.deal_stage_id ? (stageNames[d.deal_stage_id] ?? `Stage #${d.deal_stage_id}`) : "No stage";
393
+ if (!stageStats[stageName])
394
+ stageStats[stageName] = { count: 0, value: 0, deals: [] };
395
+ stageStats[stageName].count++;
396
+ const price = unwrapNumber(d.sold_price) ?? unwrapNumber(d.property?.price) ?? 0;
397
+ stageStats[stageName].value += price;
398
+ totalValue += price;
399
+ stageStats[stageName].deals.push(d);
400
+ // Check staleness
401
+ const lastUpdate = d.updated_at ?? d.created_at;
402
+ if (lastUpdate && (now.getTime() - new Date(lastUpdate).getTime()) > staleThresholdMs) {
403
+ staleDeals.push(d);
404
+ }
405
+ }
406
+ const sections = [];
407
+ // Header
408
+ const filterInfo = [];
409
+ if (args.pipeline_id)
410
+ filterInfo.push(`Pipeline: ${pipelineNames[args.pipeline_id] ?? args.pipeline_id}`);
411
+ if (args.broker_id)
412
+ filterInfo.push(`Broker ID: ${args.broker_id}`);
413
+ const filterStr = filterInfo.length ? ` (${filterInfo.join(", ")})` : "";
414
+ sections.push(`# Pipeline Summary${filterStr}\n\n` +
415
+ `Total deals: ${deals.meta?.total_count ?? totalDeals}\n` +
416
+ `Total value: ${fmtPrice(totalValue)}`);
417
+ // Stage breakdown
418
+ if (Object.keys(stageStats).length > 0) {
419
+ const stageLines = ["## Deals by Stage", ""];
420
+ stageLines.push("| Stage | Deals | Value |");
421
+ stageLines.push("|---|---|---|");
422
+ for (const [stage, stats] of Object.entries(stageStats)) {
423
+ stageLines.push(`| ${stage} | ${stats.count} | ${fmtPrice(stats.value)} |`);
424
+ }
425
+ sections.push(stageLines.join("\n"));
426
+ }
427
+ // Stale deals
428
+ if (staleDeals.length > 0) {
429
+ const staleLines = [`## Stale Deals (no update in 14+ days): ${staleDeals.length}`, ""];
430
+ for (const d of staleDeals.slice(0, 10)) {
431
+ const cName = d.client ? contactName(d.client) : `Contact #${d.client_id}`;
432
+ const propTitle = d.property ? fmt(d.property.title, "Untitled") : `Property #${d.property_id}`;
433
+ const daysStale = d.updated_at ? daysBetween(d.updated_at, now) : "?";
434
+ const stageName = d.deal_stage_id ? (stageNames[d.deal_stage_id] ?? `#${d.deal_stage_id}`) : "none";
435
+ staleLines.push(`- **Deal #${d.id}**: ${cName} → ${propTitle} (Stage: ${stageName}, ${daysStale} days since update)`);
436
+ }
437
+ sections.push(staleLines.join("\n"));
438
+ }
439
+ else {
440
+ sections.push("## Stale Deals\nNo stale deals — all deals were updated within the last 14 days.");
441
+ }
442
+ if (warnings.length > 0) {
443
+ sections.push(`\n**Note:** ${warnings.join(". ")}`);
444
+ }
445
+ return textResult(sections.join("\n\n---\n\n"));
446
+ }
447
+ catch (err) {
448
+ return errorResult("Pipeline summary", err);
449
+ }
450
+ });
451
+ // ── smart_lead_intake ───────────────────────────────────────────
452
+ server.tool("smart_lead_intake", `Complete lead intake workflow — dedup check, create/update contact,
453
+ log notes, create deal if specific property, and set follow-up reminder.
454
+
455
+ Perfect for post-call processing from a voice agent. Handles the entire
456
+ intake in one tool call:
457
+
458
+ 1. If phone or email provided → search for existing contact (dedup)
459
+ 2. If found → update contact; if not → create new contact
460
+ 3. If notes provided → log as a note task
461
+ 4. If property_id provided → create deal at first pipeline stage
462
+ 5. Create follow-up reminder for broker (due tomorrow 9am)
463
+
464
+ Returns what was done: created vs updated, IDs of all created records.`, {
465
+ first_name: z.string()
466
+ .describe("Contact first name"),
467
+ last_name: z.string()
468
+ .describe("Contact last name"),
469
+ phone: z.string().optional()
470
+ .describe("Phone number (also used for dedup search)"),
471
+ email: z.string().optional()
472
+ .describe("Email address (also used for dedup search)"),
473
+ source_id: z.number().optional()
474
+ .describe("Lead source ID (use get_contact_sources to look up)"),
475
+ broker_id: z.number().optional()
476
+ .describe("Assigned broker ID"),
477
+ notes: z.string().optional()
478
+ .describe("Call notes or free-text about the interaction"),
479
+ property_interest: z.string().optional()
480
+ .describe("Free text about what the lead is looking for (logged as note, not parsed into search profile)"),
481
+ property_id: z.number().optional()
482
+ .describe("Specific property ID the lead is interested in (creates a deal)"),
483
+ }, async (args) => {
484
+ try {
485
+ let contactId;
486
+ let action = "created";
487
+ // Step 1: Dedup search
488
+ if (args.phone || args.email) {
489
+ const searchParams = {};
490
+ if (args.phone)
491
+ searchParams["phone_number"] = args.phone;
492
+ if (args.email)
493
+ searchParams["email"] = args.email;
494
+ const existing = await client.get("/contacts", { params: searchParams });
495
+ if (existing.data && existing.data.length > 0) {
496
+ contactId = existing.data[0].id;
497
+ action = "updated";
498
+ }
499
+ }
500
+ // Step 2: Create or update contact
501
+ const contactData = {
502
+ first_name: args.first_name,
503
+ last_name: args.last_name,
504
+ };
505
+ if (args.phone)
506
+ contactData["phone"] = args.phone;
507
+ if (args.email)
508
+ contactData["email"] = args.email;
509
+ if (args.source_id)
510
+ contactData["client_source_id"] = args.source_id;
511
+ if (args.broker_id)
512
+ contactData["broker_id"] = args.broker_id;
513
+ if (contactId) {
514
+ await client.put(`/contacts/${contactId}`, { body: { client: contactData } });
515
+ }
516
+ else {
517
+ const created = await client.post("/contacts", { body: { client: contactData } });
518
+ contactId = created.id;
519
+ }
520
+ // Step 3+: Parallel tasks — note, deal, reminder
521
+ const parallelTasks = [];
522
+ let noteIdx = -1;
523
+ let dealIdx = -1;
524
+ let reminderIdx = -1;
525
+ // Note
526
+ const noteBody = [args.notes, args.property_interest ? `Interest: ${args.property_interest}` : null]
527
+ .filter(Boolean)
528
+ .join("\n\n");
529
+ if (noteBody) {
530
+ noteIdx = parallelTasks.length;
531
+ parallelTasks.push(client.post("/tasks", {
532
+ body: {
533
+ task: {
534
+ title: `Lead intake: ${args.first_name} ${args.last_name}`,
535
+ body: noteBody,
536
+ client_ids: [contactId],
537
+ broker_id: args.broker_id,
538
+ },
539
+ },
540
+ }));
541
+ }
542
+ // Deal
543
+ if (args.property_id) {
544
+ // Get first pipeline stage
545
+ const pipelines = await client.get("/deal_pipelines");
546
+ const firstPipeline = pipelines[0];
547
+ const firstStage = firstPipeline?.deal_stages?.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))[0];
548
+ if (firstStage) {
549
+ dealIdx = parallelTasks.length;
550
+ parallelTasks.push(client.post("/client_properties", {
551
+ body: {
552
+ client_property: {
553
+ client_id: contactId,
554
+ property_id: args.property_id,
555
+ deal_stage_id: firstStage.id,
556
+ deal_pipeline_id: firstPipeline.id,
557
+ broker_id: args.broker_id,
558
+ },
559
+ },
560
+ }));
561
+ }
562
+ }
563
+ // Follow-up reminder (due tomorrow 9am)
564
+ const tomorrow = new Date();
565
+ tomorrow.setDate(tomorrow.getDate() + 1);
566
+ tomorrow.setHours(9, 0, 0, 0);
567
+ reminderIdx = parallelTasks.length;
568
+ parallelTasks.push(client.post("/tasks", {
569
+ body: {
570
+ task: {
571
+ title: `Follow up: ${args.first_name} ${args.last_name}`,
572
+ is_reminder: true,
573
+ due_date: tomorrow.toISOString(),
574
+ remind_at: tomorrow.toISOString(),
575
+ done: false,
576
+ client_ids: [contactId],
577
+ property_ids: args.property_id ? [args.property_id] : undefined,
578
+ broker_id: args.broker_id,
579
+ },
580
+ },
581
+ }));
582
+ const results = await Promise.allSettled(parallelTasks);
583
+ // Build response
584
+ const lines = [
585
+ `## Lead Intake Complete`,
586
+ "",
587
+ `Action: Contact **${action}**`,
588
+ `Contact: ${args.first_name} ${args.last_name} (ID: ${contactId})`,
589
+ ];
590
+ if (noteIdx >= 0) {
591
+ const noteRes = results[noteIdx];
592
+ if (noteRes.status === "fulfilled") {
593
+ const note = noteRes.value;
594
+ lines.push(`Note: logged (ID: ${note.id})`);
595
+ }
596
+ else {
597
+ lines.push(`Note: failed to create — ${formatError(noteRes.reason)}`);
598
+ }
599
+ }
600
+ if (dealIdx >= 0) {
601
+ const dealRes = results[dealIdx];
602
+ if (dealRes.status === "fulfilled") {
603
+ const deal = dealRes.value;
604
+ lines.push(`Deal: created (ID: ${deal.id}) for property ${args.property_id}`);
605
+ }
606
+ else {
607
+ lines.push(`Deal: failed to create — ${formatError(dealRes.reason)}`);
608
+ }
609
+ }
610
+ const reminderRes = results[reminderIdx];
611
+ if (reminderRes.status === "fulfilled") {
612
+ const reminder = reminderRes.value;
613
+ lines.push(`Follow-up: reminder set for ${tomorrow.toLocaleDateString("de-DE")} 09:00 (ID: ${reminder.id})`);
614
+ }
615
+ else {
616
+ lines.push(`Follow-up: failed to create reminder — ${formatError(reminderRes.reason)}`);
617
+ }
618
+ return textResult(lines.join("\n"));
619
+ }
620
+ catch (err) {
621
+ return errorResult("Lead intake", err);
622
+ }
623
+ });
624
+ // ── match_contacts_to_property ──────────────────────────────────
625
+ server.tool("match_contacts_to_property", `Find contacts whose search profiles match a property. Returns a
626
+ ranked list with match scores.
627
+
628
+ Use when a new listing comes in to find potential buyers/renters:
629
+ "Who should I send this new listing to?"
630
+
631
+ Logic:
632
+ 1. Fetches the property details (type, price, rooms, space, city, features)
633
+ 2. Fetches active search profiles (paginates, capped by max_profiles)
634
+ 3. Scores each profile against the property on: marketing type, city,
635
+ price range, room count, living space, property type, and features
636
+ 4. Returns top 20 matches sorted by score with match/mismatch details`, {
637
+ property_id: z.number()
638
+ .describe("Property ID to find matching contacts for"),
639
+ max_profiles: z.number().optional()
640
+ .describe("Max search profiles to fetch and score (default: 1000). Caps API calls and memory for large accounts."),
641
+ }, async (args) => {
642
+ try {
643
+ // Step 1: Get property
644
+ const property = await client.get(`/units/${args.property_id}`);
645
+ // Step 2: Get search profiles (paginate, capped by max_profiles)
646
+ const maxProfiles = args.max_profiles ?? 1000;
647
+ const allProfiles = [];
648
+ let page = 1;
649
+ let hasMore = true;
650
+ while (hasMore && allProfiles.length < maxProfiles) {
651
+ const res = await client.get("/saved_queries", { params: { page, per_page: Math.min(100, maxProfiles - allProfiles.length) } });
652
+ if (res.data && res.data.length > 0) {
653
+ allProfiles.push(...res.data);
654
+ const total = res.meta?.total_count ?? 0;
655
+ hasMore = allProfiles.length < total && allProfiles.length < maxProfiles;
656
+ page++;
657
+ }
658
+ else {
659
+ hasMore = false;
660
+ }
661
+ }
662
+ if (allProfiles.length === 0) {
663
+ return textResult("No search profiles found. Cannot match contacts.");
664
+ }
665
+ const results = [];
666
+ for (const sp of allProfiles) {
667
+ if (!sp.active)
668
+ continue;
669
+ let score = 0;
670
+ let maxScore = 0;
671
+ const matches = [];
672
+ const mismatches = [];
673
+ // Marketing type (weight: 3)
674
+ if (sp.marketing_type && property.marketing_type) {
675
+ maxScore += 3;
676
+ if (sp.marketing_type === property.marketing_type) {
677
+ score += 3;
678
+ matches.push(`Type: ${sp.marketing_type}`);
679
+ }
680
+ else {
681
+ mismatches.push(`Type: wants ${sp.marketing_type}, property is ${property.marketing_type}`);
682
+ }
683
+ }
684
+ // City (weight: 3)
685
+ const propCity = fmt(property.city, "");
686
+ if (sp.cities?.length && propCity) {
687
+ maxScore += 3;
688
+ if (sp.cities.some((c) => propCity.toLowerCase().includes(c.toLowerCase()))) {
689
+ score += 3;
690
+ matches.push(`City: ${propCity}`);
691
+ }
692
+ else {
693
+ mismatches.push(`City: wants ${sp.cities.join("/")}, property in ${propCity}`);
694
+ }
695
+ }
696
+ // Price range (weight: 2)
697
+ const propPrice = unwrapNumber(property.price);
698
+ if ((sp.price !== null || sp.price_to !== null) && propPrice !== null) {
699
+ maxScore += 2;
700
+ const inRange = (sp.price === null || sp.price === undefined || propPrice >= sp.price) &&
701
+ (sp.price_to === null || sp.price_to === undefined || propPrice <= sp.price_to);
702
+ if (inRange) {
703
+ score += 2;
704
+ matches.push(`Price: ${fmtPrice(property.price)} in range`);
705
+ }
706
+ else {
707
+ mismatches.push(`Price: ${fmtPrice(property.price)} outside ${fmtPrice(sp.price)}–${fmtPrice(sp.price_to)}`);
708
+ }
709
+ }
710
+ // Rent range (weight: 2)
711
+ const propRent = unwrapNumber(property.base_rent);
712
+ if ((sp.base_rent !== null || sp.base_rent_to !== null) && propRent !== null) {
713
+ maxScore += 2;
714
+ const inRange = (sp.base_rent === null || sp.base_rent === undefined || propRent >= sp.base_rent) &&
715
+ (sp.base_rent_to === null || sp.base_rent_to === undefined || propRent <= sp.base_rent_to);
716
+ if (inRange) {
717
+ score += 2;
718
+ matches.push(`Rent: ${fmtPrice(property.base_rent)} in range`);
719
+ }
720
+ else {
721
+ mismatches.push(`Rent: ${fmtPrice(property.base_rent)} outside ${fmtPrice(sp.base_rent)}–${fmtPrice(sp.base_rent_to)}`);
722
+ }
723
+ }
724
+ // Rooms (weight: 2)
725
+ const propRooms = unwrapNumber(property.number_of_rooms);
726
+ if ((sp.number_of_rooms !== null || sp.number_of_rooms_to !== null) && propRooms !== null) {
727
+ maxScore += 2;
728
+ const inRange = (sp.number_of_rooms === null || sp.number_of_rooms === undefined || propRooms >= sp.number_of_rooms) &&
729
+ (sp.number_of_rooms_to === null || sp.number_of_rooms_to === undefined || propRooms <= sp.number_of_rooms_to);
730
+ if (inRange) {
731
+ score += 2;
732
+ matches.push(`Rooms: ${fmt(property.number_of_rooms)}`);
733
+ }
734
+ else {
735
+ mismatches.push(`Rooms: ${fmt(property.number_of_rooms)} outside ${fmt(sp.number_of_rooms)}–${fmt(sp.number_of_rooms_to)}`);
736
+ }
737
+ }
738
+ // Living space (weight: 1)
739
+ const propSpace = unwrapNumber(property.living_space);
740
+ if ((sp.living_space !== null || sp.living_space_to !== null) && propSpace !== null) {
741
+ maxScore += 1;
742
+ const inRange = (sp.living_space === null || sp.living_space === undefined || propSpace >= sp.living_space) &&
743
+ (sp.living_space_to === null || sp.living_space_to === undefined || propSpace <= sp.living_space_to);
744
+ if (inRange) {
745
+ score += 1;
746
+ matches.push(`Space: ${fmtArea(property.living_space)}`);
747
+ }
748
+ else {
749
+ mismatches.push(`Space: ${fmtArea(property.living_space)} outside ${fmt(sp.living_space)}–${fmt(sp.living_space_to)} m²`);
750
+ }
751
+ }
752
+ // Property type (weight: 2)
753
+ const propRsType = fmt(property.rs_type, "");
754
+ if (sp.rs_types?.length && propRsType) {
755
+ maxScore += 2;
756
+ if (sp.rs_types.includes(propRsType)) {
757
+ score += 2;
758
+ matches.push(`Property type: ${propRsType}`);
759
+ }
760
+ else {
761
+ mismatches.push(`Property type: wants ${sp.rs_types.join("/")}, is ${propRsType}`);
762
+ }
763
+ }
764
+ // Only include profiles that matched on at least 1 criterion
765
+ if (score > 0) {
766
+ results.push({
767
+ profileId: sp.id,
768
+ clientId: sp.client_id,
769
+ score,
770
+ maxScore,
771
+ matches,
772
+ mismatches,
773
+ });
774
+ }
775
+ }
776
+ // Sort by score descending
777
+ results.sort((a, b) => b.score - a.score || a.mismatches.length - b.mismatches.length);
778
+ const top = results.slice(0, 20);
779
+ if (top.length === 0) {
780
+ return textResult(`No matching search profiles found for property "${fmt(property.title, "Untitled")}" ` +
781
+ `(${fmt(property.marketing_type)} ${fmt(property.rs_type)}, ${fmtPrice(property.price)}, ` +
782
+ `${fmt(property.number_of_rooms)} rooms, ${fmt(property.city, "?")}).\n\n` +
783
+ `Checked ${allProfiles.length} search profiles.`);
784
+ }
785
+ const header = [
786
+ `# Matching Contacts for: ${fmt(property.title, "Untitled")} (ID: ${property.id})`,
787
+ `${fmt(property.marketing_type)} ${fmt(property.rs_type)} — ${fmtPrice(property.price)} — ${fmt(property.number_of_rooms)} rooms — ${fmt(property.city, "?")}`,
788
+ "",
789
+ `Found **${results.length}** matching profiles out of ${allProfiles.length} total (showing top ${top.length}):`,
790
+ "",
791
+ ].join("\n");
792
+ const matchLines = top.map((m, i) => {
793
+ const pct = m.maxScore > 0 ? Math.round((m.score / m.maxScore) * 100) : 0;
794
+ return [
795
+ `**${i + 1}. Contact #${m.clientId}** — Score: ${m.score}/${m.maxScore} (${pct}%) — Profile #${m.profileId}`,
796
+ m.matches.length ? ` Matches: ${m.matches.join(", ")}` : null,
797
+ m.mismatches.length ? ` Mismatches: ${m.mismatches.join(", ")}` : null,
798
+ ].filter(Boolean).join("\n");
799
+ });
800
+ return textResult(header + matchLines.join("\n\n"));
801
+ }
802
+ catch (err) {
803
+ return errorResult("Property matching", err);
804
+ }
805
+ });
806
+ }
807
+ //# sourceMappingURL=composites.js.map