@consilioweb/payload-support 0.7.0 → 0.8.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.
- package/dist/index.cjs +333 -1
- package/dist/index.js +333 -1
- package/package.json +1 -1
- package/src/collections/ClientSummaries.ts +118 -0
- package/src/collections/Tickets.ts +11 -0
- package/src/collections/index.ts +1 -0
- package/src/endpoints/client-intelligence.ts +257 -0
- package/src/endpoints/index.ts +5 -1
- package/src/plugin.ts +2 -0
package/dist/index.cjs
CHANGED
|
@@ -314,6 +314,210 @@ ${text}`;
|
|
|
314
314
|
};
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// src/endpoints/client-intelligence.ts
|
|
318
|
+
function getClient2(aiSettings) {
|
|
319
|
+
const Anthropic = __require("@anthropic-ai/sdk").default;
|
|
320
|
+
if (aiSettings.provider === "ollama") {
|
|
321
|
+
const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
|
|
322
|
+
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
323
|
+
}
|
|
324
|
+
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
325
|
+
}
|
|
326
|
+
function getModel2(aiSettings) {
|
|
327
|
+
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
328
|
+
}
|
|
329
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
330
|
+
function createClientIntelligenceEndpoint(slugs) {
|
|
331
|
+
const getHandler = async (req) => {
|
|
332
|
+
try {
|
|
333
|
+
requireAdmin(req, slugs);
|
|
334
|
+
const payload = req.payload;
|
|
335
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
336
|
+
const clientId = url.searchParams.get("clientId");
|
|
337
|
+
if (!clientId) return Response.json({ error: "clientId required" }, { status: 400 });
|
|
338
|
+
const existing = await payload.find({
|
|
339
|
+
collection: "client-summaries",
|
|
340
|
+
where: { client: { equals: Number(clientId) } },
|
|
341
|
+
limit: 1,
|
|
342
|
+
depth: 0,
|
|
343
|
+
overrideAccess: true
|
|
344
|
+
});
|
|
345
|
+
if (existing.docs.length > 0) {
|
|
346
|
+
const cached = existing.docs[0];
|
|
347
|
+
const age = Date.now() - new Date(cached.generatedAt || 0).getTime();
|
|
348
|
+
if (age < CACHE_TTL_MS) {
|
|
349
|
+
return Response.json({ ...cached, fromCache: true });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const authResponse = handleAuthError(error);
|
|
355
|
+
if (authResponse) return authResponse;
|
|
356
|
+
console.error("[client-intelligence] Error:", error);
|
|
357
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const postHandler = async (req) => {
|
|
361
|
+
try {
|
|
362
|
+
requireAdmin(req, slugs);
|
|
363
|
+
const payload = req.payload;
|
|
364
|
+
const body = await req.json?.() || {};
|
|
365
|
+
const clientId = body.clientId;
|
|
366
|
+
if (!clientId) return Response.json({ error: "clientId required" }, { status: 400 });
|
|
367
|
+
const existing = await payload.find({
|
|
368
|
+
collection: "client-summaries",
|
|
369
|
+
where: { client: { equals: Number(clientId) } },
|
|
370
|
+
limit: 1,
|
|
371
|
+
depth: 0,
|
|
372
|
+
overrideAccess: true
|
|
373
|
+
});
|
|
374
|
+
return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
const authResponse = handleAuthError(error);
|
|
377
|
+
if (authResponse) return authResponse;
|
|
378
|
+
console.error("[client-intelligence] Refresh error:", error);
|
|
379
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
return [
|
|
383
|
+
{ path: "/support/client-intelligence", method: "get", handler: getHandler },
|
|
384
|
+
{ path: "/support/client-intelligence", method: "post", handler: postHandler }
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
async function generateSummary(payload, clientId, slugs, existingId) {
|
|
388
|
+
const aiSettings = (await readSupportSettings(payload)).ai;
|
|
389
|
+
if (!aiSettings.enableSynthesis) {
|
|
390
|
+
return Response.json({ error: "AI synthesis disabled in settings" }, { status: 400 });
|
|
391
|
+
}
|
|
392
|
+
const client = await payload.findByID({
|
|
393
|
+
collection: slugs.supportClients,
|
|
394
|
+
id: Number(clientId),
|
|
395
|
+
depth: 0,
|
|
396
|
+
overrideAccess: true
|
|
397
|
+
});
|
|
398
|
+
if (!client) return Response.json({ error: "Client not found" }, { status: 404 });
|
|
399
|
+
const clientName = [client.firstName, client.lastName].filter(Boolean).join(" ") || client.company || client.email;
|
|
400
|
+
const tickets = await payload.find({
|
|
401
|
+
collection: slugs.tickets,
|
|
402
|
+
where: { client: { equals: Number(clientId) } },
|
|
403
|
+
sort: "-createdAt",
|
|
404
|
+
limit: 50,
|
|
405
|
+
depth: 0,
|
|
406
|
+
overrideAccess: true
|
|
407
|
+
});
|
|
408
|
+
if (tickets.totalDocs === 0) {
|
|
409
|
+
return Response.json({
|
|
410
|
+
summary: "Aucun ticket pour ce client.",
|
|
411
|
+
recurringTopics: [],
|
|
412
|
+
patterns: [],
|
|
413
|
+
keyFacts: [],
|
|
414
|
+
ticketCount: 0,
|
|
415
|
+
messageCount: 0
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const ticketIds = tickets.docs.slice(0, 20).map((t) => t.id);
|
|
419
|
+
const messages = await payload.find({
|
|
420
|
+
collection: slugs.ticketMessages,
|
|
421
|
+
where: { ticket: { in: ticketIds.join(",") } },
|
|
422
|
+
sort: "createdAt",
|
|
423
|
+
limit: 200,
|
|
424
|
+
depth: 0,
|
|
425
|
+
overrideAccess: true
|
|
426
|
+
});
|
|
427
|
+
let avgSatisfaction = null;
|
|
428
|
+
try {
|
|
429
|
+
const surveys = await payload.find({
|
|
430
|
+
collection: slugs.satisfactionSurveys || "satisfaction-surveys",
|
|
431
|
+
where: { client: { equals: Number(clientId) } },
|
|
432
|
+
limit: 50,
|
|
433
|
+
depth: 0,
|
|
434
|
+
overrideAccess: true
|
|
435
|
+
});
|
|
436
|
+
if (surveys.totalDocs > 0) {
|
|
437
|
+
const ratings = surveys.docs.filter((s) => s.rating).map((s) => s.rating);
|
|
438
|
+
if (ratings.length > 0) avgSatisfaction = Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
const ticketSummaries = tickets.docs.map((t) => {
|
|
443
|
+
const msgs = messages.docs.filter((m) => {
|
|
444
|
+
const mTicket = typeof m.ticket === "object" ? m.ticket.id : m.ticket;
|
|
445
|
+
return mTicket === t.id;
|
|
446
|
+
});
|
|
447
|
+
const clientMsgs = msgs.filter((m) => m.authorType === "client" || m.authorType === "email");
|
|
448
|
+
const adminMsgs = msgs.filter((m) => m.authorType === "admin");
|
|
449
|
+
return `Ticket ${t.ticketNumber} (${t.status}) \u2014 "${t.subject}"
|
|
450
|
+
Client: ${clientMsgs.map((m) => m.body?.slice(0, 200)).join(" | ")}
|
|
451
|
+
Admin: ${adminMsgs.map((m) => m.body?.slice(0, 200)).join(" | ")}`;
|
|
452
|
+
}).join("\n\n");
|
|
453
|
+
const prompt = `Tu es un assistant d'analyse CRM pour un support technique. Analyse l'historique complet de ce client et g\xE9n\xE8re un rapport structur\xE9.
|
|
454
|
+
|
|
455
|
+
CLIENT : ${clientName} (${client.company || "pas de soci\xE9t\xE9"})
|
|
456
|
+
Email : ${client.email}
|
|
457
|
+
Nombre de tickets : ${tickets.totalDocs}
|
|
458
|
+
Satisfaction moyenne : ${avgSatisfaction ?? "non \xE9valu\xE9e"}
|
|
459
|
+
|
|
460
|
+
HISTORIQUE DES TICKETS :
|
|
461
|
+
${ticketSummaries.slice(0, 4e3)}
|
|
462
|
+
|
|
463
|
+
R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette structure :
|
|
464
|
+
{
|
|
465
|
+
"summary": "R\xE9sum\xE9 global du client en 2-3 phrases (qui il est, ce qu'il demande habituellement, son niveau de satisfaction)",
|
|
466
|
+
"recurringTopics": [{"topic": "nom du sujet", "count": N, "lastSeen": "YYYY-MM-DD"}],
|
|
467
|
+
"patterns": ["pattern 1 observ\xE9", "pattern 2 observ\xE9"],
|
|
468
|
+
"keyFacts": ["fait cl\xE9 1 sur le client", "fait cl\xE9 2"]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
|
|
472
|
+
const anthropic = getClient2(aiSettings);
|
|
473
|
+
const model = getModel2(aiSettings);
|
|
474
|
+
const res = await anthropic.messages.create({
|
|
475
|
+
model,
|
|
476
|
+
max_tokens: 1e3,
|
|
477
|
+
messages: [{ role: "user", content: prompt }]
|
|
478
|
+
});
|
|
479
|
+
const rawText = res.content[0].type === "text" ? res.content[0].text : "{}";
|
|
480
|
+
let parsed = {};
|
|
481
|
+
try {
|
|
482
|
+
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
|
483
|
+
if (jsonMatch) parsed = JSON.parse(jsonMatch[0]);
|
|
484
|
+
} catch {
|
|
485
|
+
parsed = { summary: rawText, recurringTopics: [], patterns: [], keyFacts: [] };
|
|
486
|
+
}
|
|
487
|
+
const data = {
|
|
488
|
+
client: Number(clientId),
|
|
489
|
+
clientName,
|
|
490
|
+
summary: parsed.summary || "R\xE9sum\xE9 non disponible",
|
|
491
|
+
recurringTopics: parsed.recurringTopics || [],
|
|
492
|
+
patterns: parsed.patterns || [],
|
|
493
|
+
keyFacts: parsed.keyFacts || [],
|
|
494
|
+
ticketCount: tickets.totalDocs,
|
|
495
|
+
messageCount: messages.totalDocs,
|
|
496
|
+
averageSatisfaction: avgSatisfaction,
|
|
497
|
+
firstTicketAt: tickets.docs[tickets.docs.length - 1]?.createdAt || null,
|
|
498
|
+
lastTicketAt: tickets.docs[0]?.createdAt || null,
|
|
499
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
500
|
+
aiModel: model
|
|
501
|
+
};
|
|
502
|
+
let saved;
|
|
503
|
+
if (existingId) {
|
|
504
|
+
saved = await payload.update({
|
|
505
|
+
collection: "client-summaries",
|
|
506
|
+
id: existingId,
|
|
507
|
+
data,
|
|
508
|
+
overrideAccess: true
|
|
509
|
+
});
|
|
510
|
+
} else {
|
|
511
|
+
saved = await payload.create({
|
|
512
|
+
collection: "client-summaries",
|
|
513
|
+
data,
|
|
514
|
+
overrideAccess: true
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
console.log(`[client-intelligence] Generated summary for ${clientName} (${tickets.totalDocs} tickets, ${messages.totalDocs} messages)`);
|
|
518
|
+
return Response.json({ ...saved, fromCache: false });
|
|
519
|
+
}
|
|
520
|
+
|
|
317
521
|
// src/endpoints/search.ts
|
|
318
522
|
function createSearchEndpoint(slugs) {
|
|
319
523
|
return {
|
|
@@ -4765,7 +4969,10 @@ function createSupportEndpoints(slugs, options) {
|
|
|
4765
4969
|
createUserPrefsGetEndpoint(slugs),
|
|
4766
4970
|
createUserPrefsPostEndpoint(slugs)
|
|
4767
4971
|
];
|
|
4768
|
-
if (!f || f.ai !== false)
|
|
4972
|
+
if (!f || f.ai !== false) {
|
|
4973
|
+
endpoints.push(createAiEndpoint(slugs));
|
|
4974
|
+
endpoints.push(...createClientIntelligenceEndpoint(slugs));
|
|
4975
|
+
}
|
|
4769
4976
|
if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs));
|
|
4770
4977
|
if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs));
|
|
4771
4978
|
if (!f || f.splitTicket !== false) endpoints.push(createSplitTicketEndpoint(slugs));
|
|
@@ -5593,6 +5800,17 @@ function createFireTicketWebhooks(slugs) {
|
|
|
5593
5800
|
subject: doc.subject,
|
|
5594
5801
|
previousStatus: previousDoc.status
|
|
5595
5802
|
});
|
|
5803
|
+
const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
|
|
5804
|
+
if (clientId) {
|
|
5805
|
+
payload.update({
|
|
5806
|
+
collection: "client-summaries",
|
|
5807
|
+
where: { client: { equals: clientId } },
|
|
5808
|
+
data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
|
|
5809
|
+
// Force cache expiry
|
|
5810
|
+
overrideAccess: true
|
|
5811
|
+
}).catch(() => {
|
|
5812
|
+
});
|
|
5813
|
+
}
|
|
5596
5814
|
}
|
|
5597
5815
|
const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
|
|
5598
5816
|
const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
|
|
@@ -7691,6 +7909,119 @@ function createTicketStatusesCollection(slugs) {
|
|
|
7691
7909
|
};
|
|
7692
7910
|
}
|
|
7693
7911
|
|
|
7912
|
+
// src/collections/ClientSummaries.ts
|
|
7913
|
+
function createClientSummariesCollection(slugs) {
|
|
7914
|
+
return {
|
|
7915
|
+
slug: "client-summaries",
|
|
7916
|
+
labels: { singular: "R\xE9sum\xE9 client", plural: "R\xE9sum\xE9s clients" },
|
|
7917
|
+
admin: {
|
|
7918
|
+
group: "Support",
|
|
7919
|
+
hidden: true,
|
|
7920
|
+
// Not directly editable — managed via API
|
|
7921
|
+
defaultColumns: ["client", "generatedAt", "ticketCount"],
|
|
7922
|
+
useAsTitle: "clientName"
|
|
7923
|
+
},
|
|
7924
|
+
fields: [
|
|
7925
|
+
{
|
|
7926
|
+
name: "client",
|
|
7927
|
+
type: "relationship",
|
|
7928
|
+
relationTo: slugs.supportClients,
|
|
7929
|
+
required: true,
|
|
7930
|
+
unique: true,
|
|
7931
|
+
index: true,
|
|
7932
|
+
label: "Client"
|
|
7933
|
+
},
|
|
7934
|
+
{
|
|
7935
|
+
name: "clientName",
|
|
7936
|
+
type: "text",
|
|
7937
|
+
label: "Nom client",
|
|
7938
|
+
admin: { readOnly: true }
|
|
7939
|
+
},
|
|
7940
|
+
// ── AI-generated content ──
|
|
7941
|
+
{
|
|
7942
|
+
name: "summary",
|
|
7943
|
+
type: "textarea",
|
|
7944
|
+
label: "R\xE9sum\xE9 global",
|
|
7945
|
+
admin: { readOnly: true }
|
|
7946
|
+
},
|
|
7947
|
+
{
|
|
7948
|
+
name: "recurringTopics",
|
|
7949
|
+
type: "json",
|
|
7950
|
+
label: "Sujets r\xE9currents",
|
|
7951
|
+
admin: { readOnly: true }
|
|
7952
|
+
// Array of { topic: string, count: number, lastSeen: string }
|
|
7953
|
+
},
|
|
7954
|
+
{
|
|
7955
|
+
name: "patterns",
|
|
7956
|
+
type: "json",
|
|
7957
|
+
label: "Patterns d\xE9tect\xE9s",
|
|
7958
|
+
admin: { readOnly: true }
|
|
7959
|
+
// Array of strings: "Revient souvent pour X", "Préfère le tutoiement", etc.
|
|
7960
|
+
},
|
|
7961
|
+
{
|
|
7962
|
+
name: "keyFacts",
|
|
7963
|
+
type: "json",
|
|
7964
|
+
label: "Faits cl\xE9s",
|
|
7965
|
+
admin: { readOnly: true }
|
|
7966
|
+
// Array of strings: "Hébergé chez OVH", "Site WordPress", etc.
|
|
7967
|
+
},
|
|
7968
|
+
// ── Stats ──
|
|
7969
|
+
{
|
|
7970
|
+
name: "ticketCount",
|
|
7971
|
+
type: "number",
|
|
7972
|
+
label: "Nombre de tickets analys\xE9s",
|
|
7973
|
+
defaultValue: 0,
|
|
7974
|
+
admin: { readOnly: true }
|
|
7975
|
+
},
|
|
7976
|
+
{
|
|
7977
|
+
name: "messageCount",
|
|
7978
|
+
type: "number",
|
|
7979
|
+
label: "Nombre de messages analys\xE9s",
|
|
7980
|
+
defaultValue: 0,
|
|
7981
|
+
admin: { readOnly: true }
|
|
7982
|
+
},
|
|
7983
|
+
{
|
|
7984
|
+
name: "averageSatisfaction",
|
|
7985
|
+
type: "number",
|
|
7986
|
+
label: "Satisfaction moyenne",
|
|
7987
|
+
admin: { readOnly: true }
|
|
7988
|
+
},
|
|
7989
|
+
{
|
|
7990
|
+
name: "firstTicketAt",
|
|
7991
|
+
type: "date",
|
|
7992
|
+
label: "Premier ticket",
|
|
7993
|
+
admin: { readOnly: true }
|
|
7994
|
+
},
|
|
7995
|
+
{
|
|
7996
|
+
name: "lastTicketAt",
|
|
7997
|
+
type: "date",
|
|
7998
|
+
label: "Dernier ticket",
|
|
7999
|
+
admin: { readOnly: true }
|
|
8000
|
+
},
|
|
8001
|
+
// ── Meta ──
|
|
8002
|
+
{
|
|
8003
|
+
name: "generatedAt",
|
|
8004
|
+
type: "date",
|
|
8005
|
+
label: "G\xE9n\xE9r\xE9 le",
|
|
8006
|
+
admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } }
|
|
8007
|
+
},
|
|
8008
|
+
{
|
|
8009
|
+
name: "aiModel",
|
|
8010
|
+
type: "text",
|
|
8011
|
+
label: "Mod\xE8le IA utilis\xE9",
|
|
8012
|
+
admin: { readOnly: true }
|
|
8013
|
+
}
|
|
8014
|
+
],
|
|
8015
|
+
access: {
|
|
8016
|
+
create: ({ req }) => req.user?.collection === "users",
|
|
8017
|
+
read: ({ req }) => req.user?.collection === "users",
|
|
8018
|
+
update: ({ req }) => req.user?.collection === "users",
|
|
8019
|
+
delete: ({ req }) => req.user?.collection === "users"
|
|
8020
|
+
},
|
|
8021
|
+
timestamps: true
|
|
8022
|
+
};
|
|
8023
|
+
}
|
|
8024
|
+
|
|
7694
8025
|
// src/plugin.ts
|
|
7695
8026
|
function viewConfig(component, path) {
|
|
7696
8027
|
return { Component: component, path };
|
|
@@ -7735,6 +8066,7 @@ function supportPlugin(config) {
|
|
|
7735
8066
|
if (features.customStatuses !== false) supportCollections.push(createTicketStatusesCollection(slugs));
|
|
7736
8067
|
if (features.chat) supportCollections.push(createChatMessagesCollection(slugs));
|
|
7737
8068
|
if (features.pendingEmails) supportCollections.push(createPendingEmailsCollection(slugs));
|
|
8069
|
+
if (features.ai !== false) supportCollections.push(createClientSummariesCollection(slugs));
|
|
7738
8070
|
const existingViews = incomingConfig.admin?.components?.views || {};
|
|
7739
8071
|
const supportViews = {
|
|
7740
8072
|
"support-inbox": viewConfig(`${viewsBase}#TicketInboxView`, `${bp}/inbox`),
|
package/dist/index.js
CHANGED
|
@@ -308,6 +308,210 @@ ${text}`;
|
|
|
308
308
|
};
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
// src/endpoints/client-intelligence.ts
|
|
312
|
+
function getClient2(aiSettings) {
|
|
313
|
+
const Anthropic = __require("@anthropic-ai/sdk").default;
|
|
314
|
+
if (aiSettings.provider === "ollama") {
|
|
315
|
+
const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
|
|
316
|
+
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
317
|
+
}
|
|
318
|
+
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
319
|
+
}
|
|
320
|
+
function getModel2(aiSettings) {
|
|
321
|
+
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
322
|
+
}
|
|
323
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
324
|
+
function createClientIntelligenceEndpoint(slugs) {
|
|
325
|
+
const getHandler = async (req) => {
|
|
326
|
+
try {
|
|
327
|
+
requireAdmin(req, slugs);
|
|
328
|
+
const payload = req.payload;
|
|
329
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
330
|
+
const clientId = url.searchParams.get("clientId");
|
|
331
|
+
if (!clientId) return Response.json({ error: "clientId required" }, { status: 400 });
|
|
332
|
+
const existing = await payload.find({
|
|
333
|
+
collection: "client-summaries",
|
|
334
|
+
where: { client: { equals: Number(clientId) } },
|
|
335
|
+
limit: 1,
|
|
336
|
+
depth: 0,
|
|
337
|
+
overrideAccess: true
|
|
338
|
+
});
|
|
339
|
+
if (existing.docs.length > 0) {
|
|
340
|
+
const cached = existing.docs[0];
|
|
341
|
+
const age = Date.now() - new Date(cached.generatedAt || 0).getTime();
|
|
342
|
+
if (age < CACHE_TTL_MS) {
|
|
343
|
+
return Response.json({ ...cached, fromCache: true });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const authResponse = handleAuthError(error);
|
|
349
|
+
if (authResponse) return authResponse;
|
|
350
|
+
console.error("[client-intelligence] Error:", error);
|
|
351
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const postHandler = async (req) => {
|
|
355
|
+
try {
|
|
356
|
+
requireAdmin(req, slugs);
|
|
357
|
+
const payload = req.payload;
|
|
358
|
+
const body = await req.json?.() || {};
|
|
359
|
+
const clientId = body.clientId;
|
|
360
|
+
if (!clientId) return Response.json({ error: "clientId required" }, { status: 400 });
|
|
361
|
+
const existing = await payload.find({
|
|
362
|
+
collection: "client-summaries",
|
|
363
|
+
where: { client: { equals: Number(clientId) } },
|
|
364
|
+
limit: 1,
|
|
365
|
+
depth: 0,
|
|
366
|
+
overrideAccess: true
|
|
367
|
+
});
|
|
368
|
+
return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const authResponse = handleAuthError(error);
|
|
371
|
+
if (authResponse) return authResponse;
|
|
372
|
+
console.error("[client-intelligence] Refresh error:", error);
|
|
373
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
return [
|
|
377
|
+
{ path: "/support/client-intelligence", method: "get", handler: getHandler },
|
|
378
|
+
{ path: "/support/client-intelligence", method: "post", handler: postHandler }
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
async function generateSummary(payload, clientId, slugs, existingId) {
|
|
382
|
+
const aiSettings = (await readSupportSettings(payload)).ai;
|
|
383
|
+
if (!aiSettings.enableSynthesis) {
|
|
384
|
+
return Response.json({ error: "AI synthesis disabled in settings" }, { status: 400 });
|
|
385
|
+
}
|
|
386
|
+
const client = await payload.findByID({
|
|
387
|
+
collection: slugs.supportClients,
|
|
388
|
+
id: Number(clientId),
|
|
389
|
+
depth: 0,
|
|
390
|
+
overrideAccess: true
|
|
391
|
+
});
|
|
392
|
+
if (!client) return Response.json({ error: "Client not found" }, { status: 404 });
|
|
393
|
+
const clientName = [client.firstName, client.lastName].filter(Boolean).join(" ") || client.company || client.email;
|
|
394
|
+
const tickets = await payload.find({
|
|
395
|
+
collection: slugs.tickets,
|
|
396
|
+
where: { client: { equals: Number(clientId) } },
|
|
397
|
+
sort: "-createdAt",
|
|
398
|
+
limit: 50,
|
|
399
|
+
depth: 0,
|
|
400
|
+
overrideAccess: true
|
|
401
|
+
});
|
|
402
|
+
if (tickets.totalDocs === 0) {
|
|
403
|
+
return Response.json({
|
|
404
|
+
summary: "Aucun ticket pour ce client.",
|
|
405
|
+
recurringTopics: [],
|
|
406
|
+
patterns: [],
|
|
407
|
+
keyFacts: [],
|
|
408
|
+
ticketCount: 0,
|
|
409
|
+
messageCount: 0
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
const ticketIds = tickets.docs.slice(0, 20).map((t) => t.id);
|
|
413
|
+
const messages = await payload.find({
|
|
414
|
+
collection: slugs.ticketMessages,
|
|
415
|
+
where: { ticket: { in: ticketIds.join(",") } },
|
|
416
|
+
sort: "createdAt",
|
|
417
|
+
limit: 200,
|
|
418
|
+
depth: 0,
|
|
419
|
+
overrideAccess: true
|
|
420
|
+
});
|
|
421
|
+
let avgSatisfaction = null;
|
|
422
|
+
try {
|
|
423
|
+
const surveys = await payload.find({
|
|
424
|
+
collection: slugs.satisfactionSurveys || "satisfaction-surveys",
|
|
425
|
+
where: { client: { equals: Number(clientId) } },
|
|
426
|
+
limit: 50,
|
|
427
|
+
depth: 0,
|
|
428
|
+
overrideAccess: true
|
|
429
|
+
});
|
|
430
|
+
if (surveys.totalDocs > 0) {
|
|
431
|
+
const ratings = surveys.docs.filter((s) => s.rating).map((s) => s.rating);
|
|
432
|
+
if (ratings.length > 0) avgSatisfaction = Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
}
|
|
436
|
+
const ticketSummaries = tickets.docs.map((t) => {
|
|
437
|
+
const msgs = messages.docs.filter((m) => {
|
|
438
|
+
const mTicket = typeof m.ticket === "object" ? m.ticket.id : m.ticket;
|
|
439
|
+
return mTicket === t.id;
|
|
440
|
+
});
|
|
441
|
+
const clientMsgs = msgs.filter((m) => m.authorType === "client" || m.authorType === "email");
|
|
442
|
+
const adminMsgs = msgs.filter((m) => m.authorType === "admin");
|
|
443
|
+
return `Ticket ${t.ticketNumber} (${t.status}) \u2014 "${t.subject}"
|
|
444
|
+
Client: ${clientMsgs.map((m) => m.body?.slice(0, 200)).join(" | ")}
|
|
445
|
+
Admin: ${adminMsgs.map((m) => m.body?.slice(0, 200)).join(" | ")}`;
|
|
446
|
+
}).join("\n\n");
|
|
447
|
+
const prompt = `Tu es un assistant d'analyse CRM pour un support technique. Analyse l'historique complet de ce client et g\xE9n\xE8re un rapport structur\xE9.
|
|
448
|
+
|
|
449
|
+
CLIENT : ${clientName} (${client.company || "pas de soci\xE9t\xE9"})
|
|
450
|
+
Email : ${client.email}
|
|
451
|
+
Nombre de tickets : ${tickets.totalDocs}
|
|
452
|
+
Satisfaction moyenne : ${avgSatisfaction ?? "non \xE9valu\xE9e"}
|
|
453
|
+
|
|
454
|
+
HISTORIQUE DES TICKETS :
|
|
455
|
+
${ticketSummaries.slice(0, 4e3)}
|
|
456
|
+
|
|
457
|
+
R\xE9ponds en JSON strict (pas de markdown, pas de commentaires) avec cette structure :
|
|
458
|
+
{
|
|
459
|
+
"summary": "R\xE9sum\xE9 global du client en 2-3 phrases (qui il est, ce qu'il demande habituellement, son niveau de satisfaction)",
|
|
460
|
+
"recurringTopics": [{"topic": "nom du sujet", "count": N, "lastSeen": "YYYY-MM-DD"}],
|
|
461
|
+
"patterns": ["pattern 1 observ\xE9", "pattern 2 observ\xE9"],
|
|
462
|
+
"keyFacts": ["fait cl\xE9 1 sur le client", "fait cl\xE9 2"]
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
Sois factuel. Ne d\xE9passe pas 5 items par tableau. R\xE9ponds UNIQUEMENT avec le JSON.`;
|
|
466
|
+
const anthropic = getClient2(aiSettings);
|
|
467
|
+
const model = getModel2(aiSettings);
|
|
468
|
+
const res = await anthropic.messages.create({
|
|
469
|
+
model,
|
|
470
|
+
max_tokens: 1e3,
|
|
471
|
+
messages: [{ role: "user", content: prompt }]
|
|
472
|
+
});
|
|
473
|
+
const rawText = res.content[0].type === "text" ? res.content[0].text : "{}";
|
|
474
|
+
let parsed = {};
|
|
475
|
+
try {
|
|
476
|
+
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
|
477
|
+
if (jsonMatch) parsed = JSON.parse(jsonMatch[0]);
|
|
478
|
+
} catch {
|
|
479
|
+
parsed = { summary: rawText, recurringTopics: [], patterns: [], keyFacts: [] };
|
|
480
|
+
}
|
|
481
|
+
const data = {
|
|
482
|
+
client: Number(clientId),
|
|
483
|
+
clientName,
|
|
484
|
+
summary: parsed.summary || "R\xE9sum\xE9 non disponible",
|
|
485
|
+
recurringTopics: parsed.recurringTopics || [],
|
|
486
|
+
patterns: parsed.patterns || [],
|
|
487
|
+
keyFacts: parsed.keyFacts || [],
|
|
488
|
+
ticketCount: tickets.totalDocs,
|
|
489
|
+
messageCount: messages.totalDocs,
|
|
490
|
+
averageSatisfaction: avgSatisfaction,
|
|
491
|
+
firstTicketAt: tickets.docs[tickets.docs.length - 1]?.createdAt || null,
|
|
492
|
+
lastTicketAt: tickets.docs[0]?.createdAt || null,
|
|
493
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
494
|
+
aiModel: model
|
|
495
|
+
};
|
|
496
|
+
let saved;
|
|
497
|
+
if (existingId) {
|
|
498
|
+
saved = await payload.update({
|
|
499
|
+
collection: "client-summaries",
|
|
500
|
+
id: existingId,
|
|
501
|
+
data,
|
|
502
|
+
overrideAccess: true
|
|
503
|
+
});
|
|
504
|
+
} else {
|
|
505
|
+
saved = await payload.create({
|
|
506
|
+
collection: "client-summaries",
|
|
507
|
+
data,
|
|
508
|
+
overrideAccess: true
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
console.log(`[client-intelligence] Generated summary for ${clientName} (${tickets.totalDocs} tickets, ${messages.totalDocs} messages)`);
|
|
512
|
+
return Response.json({ ...saved, fromCache: false });
|
|
513
|
+
}
|
|
514
|
+
|
|
311
515
|
// src/endpoints/search.ts
|
|
312
516
|
function createSearchEndpoint(slugs) {
|
|
313
517
|
return {
|
|
@@ -4759,7 +4963,10 @@ function createSupportEndpoints(slugs, options) {
|
|
|
4759
4963
|
createUserPrefsGetEndpoint(slugs),
|
|
4760
4964
|
createUserPrefsPostEndpoint(slugs)
|
|
4761
4965
|
];
|
|
4762
|
-
if (!f || f.ai !== false)
|
|
4966
|
+
if (!f || f.ai !== false) {
|
|
4967
|
+
endpoints.push(createAiEndpoint(slugs));
|
|
4968
|
+
endpoints.push(...createClientIntelligenceEndpoint(slugs));
|
|
4969
|
+
}
|
|
4763
4970
|
if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs));
|
|
4764
4971
|
if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs));
|
|
4765
4972
|
if (!f || f.splitTicket !== false) endpoints.push(createSplitTicketEndpoint(slugs));
|
|
@@ -5587,6 +5794,17 @@ function createFireTicketWebhooks(slugs) {
|
|
|
5587
5794
|
subject: doc.subject,
|
|
5588
5795
|
previousStatus: previousDoc.status
|
|
5589
5796
|
});
|
|
5797
|
+
const clientId = typeof doc.client === "object" ? doc.client?.id : doc.client;
|
|
5798
|
+
if (clientId) {
|
|
5799
|
+
payload.update({
|
|
5800
|
+
collection: "client-summaries",
|
|
5801
|
+
where: { client: { equals: clientId } },
|
|
5802
|
+
data: { generatedAt: (/* @__PURE__ */ new Date(0)).toISOString() },
|
|
5803
|
+
// Force cache expiry
|
|
5804
|
+
overrideAccess: true
|
|
5805
|
+
}).catch(() => {
|
|
5806
|
+
});
|
|
5807
|
+
}
|
|
5590
5808
|
}
|
|
5591
5809
|
const oldAssigned = typeof previousDoc.assignedTo === "object" ? previousDoc.assignedTo?.id : previousDoc.assignedTo;
|
|
5592
5810
|
const newAssigned = typeof doc.assignedTo === "object" ? doc.assignedTo?.id : doc.assignedTo;
|
|
@@ -7685,6 +7903,119 @@ function createTicketStatusesCollection(slugs) {
|
|
|
7685
7903
|
};
|
|
7686
7904
|
}
|
|
7687
7905
|
|
|
7906
|
+
// src/collections/ClientSummaries.ts
|
|
7907
|
+
function createClientSummariesCollection(slugs) {
|
|
7908
|
+
return {
|
|
7909
|
+
slug: "client-summaries",
|
|
7910
|
+
labels: { singular: "R\xE9sum\xE9 client", plural: "R\xE9sum\xE9s clients" },
|
|
7911
|
+
admin: {
|
|
7912
|
+
group: "Support",
|
|
7913
|
+
hidden: true,
|
|
7914
|
+
// Not directly editable — managed via API
|
|
7915
|
+
defaultColumns: ["client", "generatedAt", "ticketCount"],
|
|
7916
|
+
useAsTitle: "clientName"
|
|
7917
|
+
},
|
|
7918
|
+
fields: [
|
|
7919
|
+
{
|
|
7920
|
+
name: "client",
|
|
7921
|
+
type: "relationship",
|
|
7922
|
+
relationTo: slugs.supportClients,
|
|
7923
|
+
required: true,
|
|
7924
|
+
unique: true,
|
|
7925
|
+
index: true,
|
|
7926
|
+
label: "Client"
|
|
7927
|
+
},
|
|
7928
|
+
{
|
|
7929
|
+
name: "clientName",
|
|
7930
|
+
type: "text",
|
|
7931
|
+
label: "Nom client",
|
|
7932
|
+
admin: { readOnly: true }
|
|
7933
|
+
},
|
|
7934
|
+
// ── AI-generated content ──
|
|
7935
|
+
{
|
|
7936
|
+
name: "summary",
|
|
7937
|
+
type: "textarea",
|
|
7938
|
+
label: "R\xE9sum\xE9 global",
|
|
7939
|
+
admin: { readOnly: true }
|
|
7940
|
+
},
|
|
7941
|
+
{
|
|
7942
|
+
name: "recurringTopics",
|
|
7943
|
+
type: "json",
|
|
7944
|
+
label: "Sujets r\xE9currents",
|
|
7945
|
+
admin: { readOnly: true }
|
|
7946
|
+
// Array of { topic: string, count: number, lastSeen: string }
|
|
7947
|
+
},
|
|
7948
|
+
{
|
|
7949
|
+
name: "patterns",
|
|
7950
|
+
type: "json",
|
|
7951
|
+
label: "Patterns d\xE9tect\xE9s",
|
|
7952
|
+
admin: { readOnly: true }
|
|
7953
|
+
// Array of strings: "Revient souvent pour X", "Préfère le tutoiement", etc.
|
|
7954
|
+
},
|
|
7955
|
+
{
|
|
7956
|
+
name: "keyFacts",
|
|
7957
|
+
type: "json",
|
|
7958
|
+
label: "Faits cl\xE9s",
|
|
7959
|
+
admin: { readOnly: true }
|
|
7960
|
+
// Array of strings: "Hébergé chez OVH", "Site WordPress", etc.
|
|
7961
|
+
},
|
|
7962
|
+
// ── Stats ──
|
|
7963
|
+
{
|
|
7964
|
+
name: "ticketCount",
|
|
7965
|
+
type: "number",
|
|
7966
|
+
label: "Nombre de tickets analys\xE9s",
|
|
7967
|
+
defaultValue: 0,
|
|
7968
|
+
admin: { readOnly: true }
|
|
7969
|
+
},
|
|
7970
|
+
{
|
|
7971
|
+
name: "messageCount",
|
|
7972
|
+
type: "number",
|
|
7973
|
+
label: "Nombre de messages analys\xE9s",
|
|
7974
|
+
defaultValue: 0,
|
|
7975
|
+
admin: { readOnly: true }
|
|
7976
|
+
},
|
|
7977
|
+
{
|
|
7978
|
+
name: "averageSatisfaction",
|
|
7979
|
+
type: "number",
|
|
7980
|
+
label: "Satisfaction moyenne",
|
|
7981
|
+
admin: { readOnly: true }
|
|
7982
|
+
},
|
|
7983
|
+
{
|
|
7984
|
+
name: "firstTicketAt",
|
|
7985
|
+
type: "date",
|
|
7986
|
+
label: "Premier ticket",
|
|
7987
|
+
admin: { readOnly: true }
|
|
7988
|
+
},
|
|
7989
|
+
{
|
|
7990
|
+
name: "lastTicketAt",
|
|
7991
|
+
type: "date",
|
|
7992
|
+
label: "Dernier ticket",
|
|
7993
|
+
admin: { readOnly: true }
|
|
7994
|
+
},
|
|
7995
|
+
// ── Meta ──
|
|
7996
|
+
{
|
|
7997
|
+
name: "generatedAt",
|
|
7998
|
+
type: "date",
|
|
7999
|
+
label: "G\xE9n\xE9r\xE9 le",
|
|
8000
|
+
admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } }
|
|
8001
|
+
},
|
|
8002
|
+
{
|
|
8003
|
+
name: "aiModel",
|
|
8004
|
+
type: "text",
|
|
8005
|
+
label: "Mod\xE8le IA utilis\xE9",
|
|
8006
|
+
admin: { readOnly: true }
|
|
8007
|
+
}
|
|
8008
|
+
],
|
|
8009
|
+
access: {
|
|
8010
|
+
create: ({ req }) => req.user?.collection === "users",
|
|
8011
|
+
read: ({ req }) => req.user?.collection === "users",
|
|
8012
|
+
update: ({ req }) => req.user?.collection === "users",
|
|
8013
|
+
delete: ({ req }) => req.user?.collection === "users"
|
|
8014
|
+
},
|
|
8015
|
+
timestamps: true
|
|
8016
|
+
};
|
|
8017
|
+
}
|
|
8018
|
+
|
|
7688
8019
|
// src/plugin.ts
|
|
7689
8020
|
function viewConfig(component, path) {
|
|
7690
8021
|
return { Component: component, path };
|
|
@@ -7729,6 +8060,7 @@ function supportPlugin(config) {
|
|
|
7729
8060
|
if (features.customStatuses !== false) supportCollections.push(createTicketStatusesCollection(slugs));
|
|
7730
8061
|
if (features.chat) supportCollections.push(createChatMessagesCollection(slugs));
|
|
7731
8062
|
if (features.pendingEmails) supportCollections.push(createPendingEmailsCollection(slugs));
|
|
8063
|
+
if (features.ai !== false) supportCollections.push(createClientSummariesCollection(slugs));
|
|
7732
8064
|
const existingViews = incomingConfig.admin?.components?.views || {};
|
|
7733
8065
|
const supportViews = {
|
|
7734
8066
|
"support-inbox": viewConfig(`${viewsBase}#TicketInboxView`, `${bp}/inbox`),
|
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { CollectionConfig } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Client Intelligence Summaries
|
|
6
|
+
* Stores AI-generated summaries per client: recurring topics, patterns, satisfaction trends.
|
|
7
|
+
* Summaries are cached and refreshed on-demand or when a ticket is resolved.
|
|
8
|
+
*/
|
|
9
|
+
export function createClientSummariesCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
10
|
+
return {
|
|
11
|
+
slug: 'client-summaries',
|
|
12
|
+
labels: { singular: 'Résumé client', plural: 'Résumés clients' },
|
|
13
|
+
admin: {
|
|
14
|
+
group: 'Support',
|
|
15
|
+
hidden: true, // Not directly editable — managed via API
|
|
16
|
+
defaultColumns: ['client', 'generatedAt', 'ticketCount'],
|
|
17
|
+
useAsTitle: 'clientName',
|
|
18
|
+
},
|
|
19
|
+
fields: [
|
|
20
|
+
{
|
|
21
|
+
name: 'client',
|
|
22
|
+
type: 'relationship',
|
|
23
|
+
relationTo: slugs.supportClients,
|
|
24
|
+
required: true,
|
|
25
|
+
unique: true,
|
|
26
|
+
index: true,
|
|
27
|
+
label: 'Client',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'clientName',
|
|
31
|
+
type: 'text',
|
|
32
|
+
label: 'Nom client',
|
|
33
|
+
admin: { readOnly: true },
|
|
34
|
+
},
|
|
35
|
+
// ── AI-generated content ──
|
|
36
|
+
{
|
|
37
|
+
name: 'summary',
|
|
38
|
+
type: 'textarea',
|
|
39
|
+
label: 'Résumé global',
|
|
40
|
+
admin: { readOnly: true },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'recurringTopics',
|
|
44
|
+
type: 'json',
|
|
45
|
+
label: 'Sujets récurrents',
|
|
46
|
+
admin: { readOnly: true },
|
|
47
|
+
// Array of { topic: string, count: number, lastSeen: string }
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'patterns',
|
|
51
|
+
type: 'json',
|
|
52
|
+
label: 'Patterns détectés',
|
|
53
|
+
admin: { readOnly: true },
|
|
54
|
+
// Array of strings: "Revient souvent pour X", "Préfère le tutoiement", etc.
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'keyFacts',
|
|
58
|
+
type: 'json',
|
|
59
|
+
label: 'Faits clés',
|
|
60
|
+
admin: { readOnly: true },
|
|
61
|
+
// Array of strings: "Hébergé chez OVH", "Site WordPress", etc.
|
|
62
|
+
},
|
|
63
|
+
// ── Stats ──
|
|
64
|
+
{
|
|
65
|
+
name: 'ticketCount',
|
|
66
|
+
type: 'number',
|
|
67
|
+
label: 'Nombre de tickets analysés',
|
|
68
|
+
defaultValue: 0,
|
|
69
|
+
admin: { readOnly: true },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'messageCount',
|
|
73
|
+
type: 'number',
|
|
74
|
+
label: 'Nombre de messages analysés',
|
|
75
|
+
defaultValue: 0,
|
|
76
|
+
admin: { readOnly: true },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'averageSatisfaction',
|
|
80
|
+
type: 'number',
|
|
81
|
+
label: 'Satisfaction moyenne',
|
|
82
|
+
admin: { readOnly: true },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'firstTicketAt',
|
|
86
|
+
type: 'date',
|
|
87
|
+
label: 'Premier ticket',
|
|
88
|
+
admin: { readOnly: true },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'lastTicketAt',
|
|
92
|
+
type: 'date',
|
|
93
|
+
label: 'Dernier ticket',
|
|
94
|
+
admin: { readOnly: true },
|
|
95
|
+
},
|
|
96
|
+
// ── Meta ──
|
|
97
|
+
{
|
|
98
|
+
name: 'generatedAt',
|
|
99
|
+
type: 'date',
|
|
100
|
+
label: 'Généré le',
|
|
101
|
+
admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } },
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'aiModel',
|
|
105
|
+
type: 'text',
|
|
106
|
+
label: 'Modèle IA utilisé',
|
|
107
|
+
admin: { readOnly: true },
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
access: {
|
|
111
|
+
create: ({ req }) => req.user?.collection === 'users',
|
|
112
|
+
read: ({ req }) => req.user?.collection === 'users',
|
|
113
|
+
update: ({ req }) => req.user?.collection === 'users',
|
|
114
|
+
delete: ({ req }) => req.user?.collection === 'users',
|
|
115
|
+
},
|
|
116
|
+
timestamps: true,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -343,6 +343,17 @@ function createFireTicketWebhooks(slugs: CollectionSlugs): CollectionAfterChange
|
|
|
343
343
|
subject: doc.subject,
|
|
344
344
|
previousStatus: previousDoc.status,
|
|
345
345
|
})
|
|
346
|
+
|
|
347
|
+
// Invalidate client summary cache so it refreshes on next view
|
|
348
|
+
const clientId = typeof doc.client === 'object' ? doc.client?.id : doc.client
|
|
349
|
+
if (clientId) {
|
|
350
|
+
payload.update({
|
|
351
|
+
collection: 'client-summaries',
|
|
352
|
+
where: { client: { equals: clientId } },
|
|
353
|
+
data: { generatedAt: new Date(0).toISOString() }, // Force cache expiry
|
|
354
|
+
overrideAccess: true,
|
|
355
|
+
}).catch(() => { /* silent — collection might not exist yet */ })
|
|
356
|
+
}
|
|
346
357
|
}
|
|
347
358
|
|
|
348
359
|
// ticket_assigned
|
package/src/collections/index.ts
CHANGED
|
@@ -14,3 +14,4 @@ export { createWebhookEndpointsCollection } from './WebhookEndpoints'
|
|
|
14
14
|
export { createSlaPoliciesCollection } from './SlaPolicies'
|
|
15
15
|
export { createMacrosCollection } from './Macros'
|
|
16
16
|
export { createTicketStatusesCollection } from './TicketStatuses'
|
|
17
|
+
export { createClientSummariesCollection } from './ClientSummaries'
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
|
|
5
|
+
|
|
6
|
+
function getClient(aiSettings: SupportSettings['ai']) {
|
|
7
|
+
const Anthropic = require('@anthropic-ai/sdk').default
|
|
8
|
+
if (aiSettings.provider === 'ollama') {
|
|
9
|
+
const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
|
|
10
|
+
return new Anthropic({ apiKey: 'ollama', baseURL })
|
|
11
|
+
}
|
|
12
|
+
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getModel(aiSettings: SupportSettings['ai']): string {
|
|
16
|
+
return aiSettings.model || 'claude-haiku-4-5-20251001'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* GET /api/support/client-intelligence?clientId=X
|
|
23
|
+
* Returns cached summary or generates a new one.
|
|
24
|
+
*
|
|
25
|
+
* POST /api/support/client-intelligence?clientId=X
|
|
26
|
+
* Force-refreshes the summary.
|
|
27
|
+
*/
|
|
28
|
+
export function createClientIntelligenceEndpoint(slugs: CollectionSlugs): Endpoint[] {
|
|
29
|
+
const getHandler = async (req: any) => {
|
|
30
|
+
try {
|
|
31
|
+
requireAdmin(req, slugs)
|
|
32
|
+
const payload = req.payload
|
|
33
|
+
const url = new URL(req.url || '', 'http://localhost')
|
|
34
|
+
const clientId = url.searchParams.get('clientId')
|
|
35
|
+
if (!clientId) return Response.json({ error: 'clientId required' }, { status: 400 })
|
|
36
|
+
|
|
37
|
+
// Check cache
|
|
38
|
+
const existing = await payload.find({
|
|
39
|
+
collection: 'client-summaries',
|
|
40
|
+
where: { client: { equals: Number(clientId) } },
|
|
41
|
+
limit: 1,
|
|
42
|
+
depth: 0,
|
|
43
|
+
overrideAccess: true,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (existing.docs.length > 0) {
|
|
47
|
+
const cached = existing.docs[0]
|
|
48
|
+
const age = Date.now() - new Date(cached.generatedAt || 0).getTime()
|
|
49
|
+
if (age < CACHE_TTL_MS) {
|
|
50
|
+
return Response.json({ ...cached, fromCache: true })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Generate new summary
|
|
55
|
+
return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const authResponse = handleAuthError(error)
|
|
58
|
+
if (authResponse) return authResponse
|
|
59
|
+
console.error('[client-intelligence] Error:', error)
|
|
60
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const postHandler = async (req: any) => {
|
|
65
|
+
try {
|
|
66
|
+
requireAdmin(req, slugs)
|
|
67
|
+
const payload = req.payload
|
|
68
|
+
const body = await req.json?.() || {}
|
|
69
|
+
const clientId = body.clientId
|
|
70
|
+
if (!clientId) return Response.json({ error: 'clientId required' }, { status: 400 })
|
|
71
|
+
|
|
72
|
+
// Find existing to update
|
|
73
|
+
const existing = await payload.find({
|
|
74
|
+
collection: 'client-summaries',
|
|
75
|
+
where: { client: { equals: Number(clientId) } },
|
|
76
|
+
limit: 1,
|
|
77
|
+
depth: 0,
|
|
78
|
+
overrideAccess: true,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const authResponse = handleAuthError(error)
|
|
84
|
+
if (authResponse) return authResponse
|
|
85
|
+
console.error('[client-intelligence] Refresh error:', error)
|
|
86
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
{ path: '/support/client-intelligence', method: 'get', handler: getHandler },
|
|
92
|
+
{ path: '/support/client-intelligence', method: 'post', handler: postHandler },
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function generateSummary(
|
|
97
|
+
payload: any,
|
|
98
|
+
clientId: string,
|
|
99
|
+
slugs: CollectionSlugs,
|
|
100
|
+
existingId?: number,
|
|
101
|
+
) {
|
|
102
|
+
const aiSettings = (await readSupportSettings(payload)).ai
|
|
103
|
+
if (!aiSettings.enableSynthesis) {
|
|
104
|
+
return Response.json({ error: 'AI synthesis disabled in settings' }, { status: 400 })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 1. Fetch client info
|
|
108
|
+
const client = await payload.findByID({
|
|
109
|
+
collection: slugs.supportClients,
|
|
110
|
+
id: Number(clientId),
|
|
111
|
+
depth: 0,
|
|
112
|
+
overrideAccess: true,
|
|
113
|
+
})
|
|
114
|
+
if (!client) return Response.json({ error: 'Client not found' }, { status: 404 })
|
|
115
|
+
|
|
116
|
+
const clientName = [client.firstName, client.lastName].filter(Boolean).join(' ') || client.company || client.email
|
|
117
|
+
|
|
118
|
+
// 2. Fetch all tickets for this client
|
|
119
|
+
const tickets = await payload.find({
|
|
120
|
+
collection: slugs.tickets,
|
|
121
|
+
where: { client: { equals: Number(clientId) } },
|
|
122
|
+
sort: '-createdAt',
|
|
123
|
+
limit: 50,
|
|
124
|
+
depth: 0,
|
|
125
|
+
overrideAccess: true,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if (tickets.totalDocs === 0) {
|
|
129
|
+
return Response.json({
|
|
130
|
+
summary: 'Aucun ticket pour ce client.',
|
|
131
|
+
recurringTopics: [],
|
|
132
|
+
patterns: [],
|
|
133
|
+
keyFacts: [],
|
|
134
|
+
ticketCount: 0,
|
|
135
|
+
messageCount: 0,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 3. Fetch messages for recent tickets (last 20)
|
|
140
|
+
const ticketIds = tickets.docs.slice(0, 20).map((t: any) => t.id)
|
|
141
|
+
const messages = await payload.find({
|
|
142
|
+
collection: slugs.ticketMessages,
|
|
143
|
+
where: { ticket: { in: ticketIds.join(',') } },
|
|
144
|
+
sort: 'createdAt',
|
|
145
|
+
limit: 200,
|
|
146
|
+
depth: 0,
|
|
147
|
+
overrideAccess: true,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// 4. Fetch satisfaction surveys
|
|
151
|
+
let avgSatisfaction: number | null = null
|
|
152
|
+
try {
|
|
153
|
+
const surveys = await payload.find({
|
|
154
|
+
collection: slugs.satisfactionSurveys || 'satisfaction-surveys',
|
|
155
|
+
where: { client: { equals: Number(clientId) } },
|
|
156
|
+
limit: 50,
|
|
157
|
+
depth: 0,
|
|
158
|
+
overrideAccess: true,
|
|
159
|
+
})
|
|
160
|
+
if (surveys.totalDocs > 0) {
|
|
161
|
+
const ratings = surveys.docs.filter((s: any) => s.rating).map((s: any) => s.rating)
|
|
162
|
+
if (ratings.length > 0) avgSatisfaction = Math.round((ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length) * 10) / 10
|
|
163
|
+
}
|
|
164
|
+
} catch { /* satisfaction collection might not exist */ }
|
|
165
|
+
|
|
166
|
+
// 5. Build context for AI
|
|
167
|
+
const ticketSummaries = tickets.docs.map((t: any) => {
|
|
168
|
+
const msgs = messages.docs.filter((m: any) => {
|
|
169
|
+
const mTicket = typeof m.ticket === 'object' ? m.ticket.id : m.ticket
|
|
170
|
+
return mTicket === t.id
|
|
171
|
+
})
|
|
172
|
+
const clientMsgs = msgs.filter((m: any) => m.authorType === 'client' || m.authorType === 'email')
|
|
173
|
+
const adminMsgs = msgs.filter((m: any) => m.authorType === 'admin')
|
|
174
|
+
return `Ticket ${t.ticketNumber} (${t.status}) — "${t.subject}"
|
|
175
|
+
Client: ${clientMsgs.map((m: any) => m.body?.slice(0, 200)).join(' | ')}
|
|
176
|
+
Admin: ${adminMsgs.map((m: any) => m.body?.slice(0, 200)).join(' | ')}`
|
|
177
|
+
}).join('\n\n')
|
|
178
|
+
|
|
179
|
+
const prompt = `Tu es un assistant d'analyse CRM pour un support technique. Analyse l'historique complet de ce client et génère un rapport structuré.
|
|
180
|
+
|
|
181
|
+
CLIENT : ${clientName} (${client.company || 'pas de société'})
|
|
182
|
+
Email : ${client.email}
|
|
183
|
+
Nombre de tickets : ${tickets.totalDocs}
|
|
184
|
+
Satisfaction moyenne : ${avgSatisfaction ?? 'non évaluée'}
|
|
185
|
+
|
|
186
|
+
HISTORIQUE DES TICKETS :
|
|
187
|
+
${ticketSummaries.slice(0, 4000)}
|
|
188
|
+
|
|
189
|
+
Réponds en JSON strict (pas de markdown, pas de commentaires) avec cette structure :
|
|
190
|
+
{
|
|
191
|
+
"summary": "Résumé global du client en 2-3 phrases (qui il est, ce qu'il demande habituellement, son niveau de satisfaction)",
|
|
192
|
+
"recurringTopics": [{"topic": "nom du sujet", "count": N, "lastSeen": "YYYY-MM-DD"}],
|
|
193
|
+
"patterns": ["pattern 1 observé", "pattern 2 observé"],
|
|
194
|
+
"keyFacts": ["fait clé 1 sur le client", "fait clé 2"]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
Sois factuel. Ne dépasse pas 5 items par tableau. Réponds UNIQUEMENT avec le JSON.`
|
|
198
|
+
|
|
199
|
+
// 6. Call AI
|
|
200
|
+
const anthropic = getClient(aiSettings)
|
|
201
|
+
const model = getModel(aiSettings)
|
|
202
|
+
|
|
203
|
+
const res = await anthropic.messages.create({
|
|
204
|
+
model,
|
|
205
|
+
max_tokens: 1000,
|
|
206
|
+
messages: [{ role: 'user', content: prompt }],
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const rawText = res.content[0].type === 'text' ? res.content[0].text : '{}'
|
|
210
|
+
|
|
211
|
+
// 7. Parse AI response
|
|
212
|
+
let parsed: any = {}
|
|
213
|
+
try {
|
|
214
|
+
// Extract JSON from potential markdown fences
|
|
215
|
+
const jsonMatch = rawText.match(/\{[\s\S]*\}/)
|
|
216
|
+
if (jsonMatch) parsed = JSON.parse(jsonMatch[0])
|
|
217
|
+
} catch {
|
|
218
|
+
parsed = { summary: rawText, recurringTopics: [], patterns: [], keyFacts: [] }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 8. Save to DB
|
|
222
|
+
const data = {
|
|
223
|
+
client: Number(clientId),
|
|
224
|
+
clientName,
|
|
225
|
+
summary: parsed.summary || 'Résumé non disponible',
|
|
226
|
+
recurringTopics: parsed.recurringTopics || [],
|
|
227
|
+
patterns: parsed.patterns || [],
|
|
228
|
+
keyFacts: parsed.keyFacts || [],
|
|
229
|
+
ticketCount: tickets.totalDocs,
|
|
230
|
+
messageCount: messages.totalDocs,
|
|
231
|
+
averageSatisfaction: avgSatisfaction,
|
|
232
|
+
firstTicketAt: tickets.docs[tickets.docs.length - 1]?.createdAt || null,
|
|
233
|
+
lastTicketAt: tickets.docs[0]?.createdAt || null,
|
|
234
|
+
generatedAt: new Date().toISOString(),
|
|
235
|
+
aiModel: model,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let saved: any
|
|
239
|
+
if (existingId) {
|
|
240
|
+
saved = await payload.update({
|
|
241
|
+
collection: 'client-summaries',
|
|
242
|
+
id: existingId,
|
|
243
|
+
data,
|
|
244
|
+
overrideAccess: true,
|
|
245
|
+
})
|
|
246
|
+
} else {
|
|
247
|
+
saved = await payload.create({
|
|
248
|
+
collection: 'client-summaries',
|
|
249
|
+
data,
|
|
250
|
+
overrideAccess: true,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
console.log(`[client-intelligence] Generated summary for ${clientName} (${tickets.totalDocs} tickets, ${messages.totalDocs} messages)`)
|
|
255
|
+
|
|
256
|
+
return Response.json({ ...saved, fromCache: false })
|
|
257
|
+
}
|
package/src/endpoints/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import type { SupportFeatures } from '../types'
|
|
4
4
|
|
|
5
5
|
import { createAiEndpoint } from './ai'
|
|
6
|
+
import { createClientIntelligenceEndpoint } from './client-intelligence'
|
|
6
7
|
import { createSearchEndpoint } from './search'
|
|
7
8
|
import { createBulkActionEndpoint } from './bulk-action'
|
|
8
9
|
import { createMergeTicketsEndpoint } from './merge-tickets'
|
|
@@ -117,7 +118,10 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
|
|
|
117
118
|
]
|
|
118
119
|
|
|
119
120
|
// Conditional endpoints based on feature flags
|
|
120
|
-
if (!f || f.ai !== false)
|
|
121
|
+
if (!f || f.ai !== false) {
|
|
122
|
+
endpoints.push(createAiEndpoint(slugs))
|
|
123
|
+
endpoints.push(...createClientIntelligenceEndpoint(slugs))
|
|
124
|
+
}
|
|
121
125
|
if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
|
|
122
126
|
if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
|
|
123
127
|
if (!f || f.splitTicket !== false) endpoints.push(createSplitTicketEndpoint(slugs))
|
package/src/plugin.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
createSlaPoliciesCollection,
|
|
21
21
|
createMacrosCollection,
|
|
22
22
|
createTicketStatusesCollection,
|
|
23
|
+
createClientSummariesCollection,
|
|
23
24
|
} from './collections'
|
|
24
25
|
|
|
25
26
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
@@ -99,6 +100,7 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
99
100
|
if (features.customStatuses !== false) supportCollections.push(createTicketStatusesCollection(slugs))
|
|
100
101
|
if (features.chat) supportCollections.push(createChatMessagesCollection(slugs))
|
|
101
102
|
if (features.pendingEmails) supportCollections.push(createPendingEmailsCollection(slugs))
|
|
103
|
+
if (features.ai !== false) supportCollections.push(createClientSummariesCollection(slugs))
|
|
102
104
|
|
|
103
105
|
// ─── Admin Views ─────────────────────────────────────
|
|
104
106
|
|