@consilioweb/payload-support 0.7.0 → 0.8.1

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 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) endpoints.push(createAiEndpoint(slugs));
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`),