@consilioweb/payload-support 0.10.1 → 0.12.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 (39) hide show
  1. package/dist/components/TicketConversation/locales/en.json +25 -1
  2. package/dist/components/TicketConversation/locales/fr.json +25 -1
  3. package/dist/index.cjs +799 -4
  4. package/dist/index.d.cts +15 -1
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +800 -6
  7. package/dist/styles/TicketDetail.module.scss +899 -353
  8. package/dist/views/TicketDetailView/client.cjs +504 -282
  9. package/dist/views/TicketDetailView/client.js +505 -283
  10. package/package.json +1 -1
  11. package/src/collections/ClientSummaries.ts +16 -0
  12. package/src/collections/TicketCollaborators.ts +93 -0
  13. package/src/collections/TicketFeedback.ts +116 -0
  14. package/src/collections/TicketMessages.ts +9 -0
  15. package/src/collections/Tickets.ts +31 -1
  16. package/src/collections/index.ts +2 -0
  17. package/src/components/TicketConversation/locales/en.json +25 -1
  18. package/src/components/TicketConversation/locales/fr.json +25 -1
  19. package/src/endpoints/escalate.ts +44 -0
  20. package/src/endpoints/index.ts +15 -0
  21. package/src/endpoints/invite-collaborator.ts +215 -0
  22. package/src/endpoints/kb-search.ts +156 -0
  23. package/src/endpoints/ticket-feedback.ts +104 -0
  24. package/src/endpoints/transfer-ticket.ts +248 -0
  25. package/src/index.ts +1 -0
  26. package/src/plugin.ts +4 -0
  27. package/src/portal/auth/ChatWidget.tsx +11 -0
  28. package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
  29. package/src/portal/auth/dashboard/page.tsx +11 -2
  30. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
  31. package/src/portal/auth/tickets/detail/page.tsx +122 -23
  32. package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
  33. package/src/portal/auth/tickets/new/page.tsx +203 -100
  34. package/src/portal/locales/en.json +5 -0
  35. package/src/portal/locales/fr.json +5 -0
  36. package/src/styles/TicketDetail.module.scss +899 -353
  37. package/src/types.ts +19 -0
  38. package/src/utils/slugs.ts +2 -0
  39. package/src/views/TicketDetailView/client.tsx +404 -121
package/dist/index.cjs CHANGED
@@ -63,6 +63,7 @@ var DEFAULT_SLUGS = {
63
63
  slaPolicies: "sla-policies",
64
64
  macros: "macros",
65
65
  ticketStatuses: "ticket-statuses",
66
+ ticketFeedback: "ticket-feedback",
66
67
  users: "users",
67
68
  media: "media"
68
69
  };
@@ -618,6 +619,104 @@ function createSearchEndpoint(slugs) {
618
619
  };
619
620
  }
620
621
 
622
+ // src/endpoints/kb-search.ts
623
+ var DIACRITICS = /[̀-ͯ]/g;
624
+ function lexicalToPlainText(richText) {
625
+ if (!richText) return "";
626
+ const parts = [];
627
+ const visit = (node) => {
628
+ if (!node || typeof node !== "object") return;
629
+ if (typeof node.text === "string") {
630
+ parts.push(node.text);
631
+ }
632
+ if (Array.isArray(node.children)) {
633
+ for (const child of node.children) visit(child);
634
+ }
635
+ if (node.root) visit(node.root);
636
+ };
637
+ visit(richText);
638
+ return parts.join(" ").replace(/\s+/g, " ").trim();
639
+ }
640
+ function buildExcerpt(plain, maxChars = 160) {
641
+ if (!plain) return "";
642
+ if (plain.length <= maxChars) return plain;
643
+ const slice = plain.slice(0, maxChars + 1);
644
+ const lastSpace = slice.lastIndexOf(" ");
645
+ const cut = lastSpace > 60 ? slice.slice(0, lastSpace) : slice.slice(0, maxChars);
646
+ return `${cut.trim()}\u2026`;
647
+ }
648
+ function tokenize(q) {
649
+ return q.toLowerCase().normalize("NFD").replace(DIACRITICS, "").split(/[^a-z0-9]+/).filter((t) => t.length >= 2);
650
+ }
651
+ function normalize(s) {
652
+ return s.toLowerCase().normalize("NFD").replace(DIACRITICS, "");
653
+ }
654
+ function createKbSearchEndpoint(slugs) {
655
+ return {
656
+ path: "/support/kb/search",
657
+ method: "get",
658
+ handler: async (req) => {
659
+ try {
660
+ const payload = req.payload;
661
+ const url = new URL(req.url);
662
+ const q = (url.searchParams.get("q") || "").trim();
663
+ const limitRaw = parseInt(url.searchParams.get("limit") || "5", 10);
664
+ const limit = Math.min(Math.max(Number.isNaN(limitRaw) ? 5 : limitRaw, 1), 10);
665
+ if (!q || q.length < 3) {
666
+ return Response.json({ results: [], total: 0 });
667
+ }
668
+ const tokens = tokenize(q);
669
+ if (tokens.length === 0) {
670
+ return Response.json({ results: [], total: 0 });
671
+ }
672
+ const orClauses = [];
673
+ for (const t of tokens) {
674
+ orClauses.push({ title: { contains: t } });
675
+ orClauses.push({ category: { contains: t } });
676
+ }
677
+ orClauses.push({ title: { contains: q } });
678
+ const res = await payload.find({
679
+ collection: slugs.knowledgeBase,
680
+ where: {
681
+ and: [
682
+ { published: { equals: true } },
683
+ { or: orClauses }
684
+ ]
685
+ },
686
+ // Over-fetch so we can re-rank and trim ourselves.
687
+ limit: Math.max(limit * 3, 10),
688
+ depth: 0,
689
+ overrideAccess: true
690
+ });
691
+ const results = res.docs.map((a) => {
692
+ const title = a.title || "";
693
+ const category = a.category || "";
694
+ const titleN = normalize(title);
695
+ const categoryN = normalize(category);
696
+ let score = 0;
697
+ for (const t of tokens) {
698
+ if (titleN.includes(t)) score += 2;
699
+ if (categoryN.includes(t)) score += 1;
700
+ }
701
+ const plain = lexicalToPlainText(a.body);
702
+ return {
703
+ id: a.id,
704
+ title,
705
+ slug: a.slug,
706
+ category,
707
+ excerpt: buildExcerpt(plain, 160),
708
+ score
709
+ };
710
+ }).filter((r) => r.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
711
+ return Response.json({ results, total: results.length });
712
+ } catch (error) {
713
+ console.error("[kb-search] Error:", error);
714
+ return Response.json({ error: "Internal server error" }, { status: 500 });
715
+ }
716
+ }
717
+ };
718
+ }
719
+
621
720
  // src/endpoints/bulk-action.ts
622
721
  function createBulkActionEndpoint(slugs) {
623
722
  return {
@@ -5210,11 +5309,467 @@ function createUserPrefsPostEndpoint(slugs) {
5210
5309
  };
5211
5310
  }
5212
5311
 
5312
+ // src/endpoints/escalate.ts
5313
+ function createEscalateEndpoint(slugs) {
5314
+ return {
5315
+ path: "/support/tickets/:id/escalate",
5316
+ method: "post",
5317
+ handler: async (req) => {
5318
+ try {
5319
+ requireAdmin(req, slugs);
5320
+ const idRaw = req.routeParams?.id;
5321
+ if (!idRaw) {
5322
+ return Response.json({ error: "missing-id" }, { status: 400 });
5323
+ }
5324
+ const ticketId = Number(idRaw);
5325
+ if (Number.isNaN(ticketId)) {
5326
+ return Response.json({ error: "invalid-id" }, { status: 400 });
5327
+ }
5328
+ const ticket = await req.payload.update({
5329
+ collection: slugs.tickets,
5330
+ id: ticketId,
5331
+ data: { status: "escalated" },
5332
+ overrideAccess: true
5333
+ });
5334
+ return Response.json({ ok: true, ticket });
5335
+ } catch (err) {
5336
+ const authResponse = handleAuthError(err);
5337
+ if (authResponse) return authResponse;
5338
+ console.error("[support/escalate] Error:", err);
5339
+ return Response.json({ error: "Internal server error" }, { status: 500 });
5340
+ }
5341
+ }
5342
+ };
5343
+ }
5344
+
5345
+ // src/endpoints/ticket-feedback.ts
5346
+ function createTicketFeedbackEndpoint(slugs) {
5347
+ return {
5348
+ path: "/support/tickets/:id/feedback",
5349
+ method: "post",
5350
+ handler: async (req) => {
5351
+ try {
5352
+ requireClient(req, slugs);
5353
+ const payload = req.payload;
5354
+ const idRaw = req.routeParams?.id;
5355
+ if (!idRaw) {
5356
+ return Response.json({ error: "missing-id" }, { status: 400 });
5357
+ }
5358
+ const ticketId = Number(idRaw);
5359
+ if (Number.isNaN(ticketId)) {
5360
+ return Response.json({ error: "invalid-id" }, { status: 400 });
5361
+ }
5362
+ const body = await req.json();
5363
+ const ratingNum = Number(body?.rating);
5364
+ if (!Number.isInteger(ratingNum) || ratingNum < 1 || ratingNum > 5) {
5365
+ return Response.json(
5366
+ { error: "rating (entier 1-5) requis." },
5367
+ { status: 400 }
5368
+ );
5369
+ }
5370
+ const comment = typeof body?.comment === "string" ? body.comment.trim() : void 0;
5371
+ if (comment && comment.length > 5e3) {
5372
+ return Response.json(
5373
+ { error: "Le commentaire ne peut pas depasser 5000 caracteres." },
5374
+ { status: 400 }
5375
+ );
5376
+ }
5377
+ const ticket = await payload.findByID({
5378
+ collection: slugs.tickets,
5379
+ id: ticketId,
5380
+ depth: 0,
5381
+ overrideAccess: true
5382
+ });
5383
+ if (!ticket) {
5384
+ return Response.json({ error: "Ticket introuvable." }, { status: 404 });
5385
+ }
5386
+ const ticketClientId = typeof ticket.client === "object" && ticket.client !== null ? ticket.client.id : ticket.client;
5387
+ if (ticketClientId !== req.user.id) {
5388
+ return Response.json({ error: "forbidden" }, { status: 403 });
5389
+ }
5390
+ const existing = await payload.find({
5391
+ collection: "ticket-feedback",
5392
+ where: { ticket: { equals: ticketId } },
5393
+ limit: 1,
5394
+ depth: 0,
5395
+ overrideAccess: true
5396
+ });
5397
+ if (existing.docs.length > 0) {
5398
+ return Response.json(
5399
+ { error: "Feedback deja soumis pour ce ticket." },
5400
+ { status: 409 }
5401
+ );
5402
+ }
5403
+ const feedback = await payload.create({
5404
+ collection: "ticket-feedback",
5405
+ data: {
5406
+ ticket: ticketId,
5407
+ client: req.user.id,
5408
+ rating: ratingNum,
5409
+ ...comment ? { comment } : {},
5410
+ submittedFrom: "portal"
5411
+ },
5412
+ overrideAccess: true
5413
+ });
5414
+ return Response.json({ success: true, feedback });
5415
+ } catch (err) {
5416
+ const authResponse = handleAuthError(err);
5417
+ if (authResponse) return authResponse;
5418
+ console.error("[support/ticket-feedback] Error:", err);
5419
+ return Response.json({ error: "Internal server error" }, { status: 500 });
5420
+ }
5421
+ }
5422
+ };
5423
+ }
5424
+
5425
+ // src/endpoints/transfer-ticket.ts
5426
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5427
+ var MAX_TRANSFERS_PER_DAY = 5;
5428
+ function createTransferTicketEndpoint(slugs) {
5429
+ return {
5430
+ path: "/support/tickets/:id/transfer",
5431
+ method: "post",
5432
+ handler: async (req) => {
5433
+ try {
5434
+ if (!req.user) throw new AuthError("Authentication required", 401);
5435
+ const idRaw = req.routeParams?.id;
5436
+ if (!idRaw) return Response.json({ error: "missing-id" }, { status: 400 });
5437
+ const ticketId = Number(idRaw);
5438
+ if (Number.isNaN(ticketId)) return Response.json({ error: "invalid-id" }, { status: 400 });
5439
+ let body;
5440
+ try {
5441
+ body = await req.json();
5442
+ } catch {
5443
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5444
+ }
5445
+ const email = (body.email || "").trim().toLowerCase();
5446
+ if (!email || !EMAIL_RE.test(email) || email.length > 254) {
5447
+ return Response.json({ error: "Email invalide" }, { status: 400 });
5448
+ }
5449
+ const includeAttachments = body.includeAttachments !== false;
5450
+ const customMessage = typeof body.message === "string" ? body.message.slice(0, 1e3) : "";
5451
+ const payload = req.payload;
5452
+ const ticket = await payload.findByID({
5453
+ collection: slugs.tickets,
5454
+ id: ticketId,
5455
+ depth: 1,
5456
+ overrideAccess: true
5457
+ });
5458
+ if (!ticket) return Response.json({ error: "Ticket introuvable" }, { status: 404 });
5459
+ const isAdmin = req.user.collection === slugs.users;
5460
+ const isOwner = req.user.collection === slugs.supportClients && (typeof ticket.client === "object" ? ticket.client?.id === req.user.id : ticket.client === req.user.id);
5461
+ if (!isAdmin && !isOwner) {
5462
+ return Response.json({ error: "Forbidden" }, { status: 403 });
5463
+ }
5464
+ try {
5465
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
5466
+ const existing = await payload.count({
5467
+ collection: slugs.emailLogs,
5468
+ where: {
5469
+ and: [
5470
+ { action: { equals: "transfer" } },
5471
+ { ticketNumber: { equals: ticket.ticketNumber || `#${ticketId}` } },
5472
+ { createdAt: { greater_than: since } }
5473
+ ]
5474
+ },
5475
+ overrideAccess: true
5476
+ });
5477
+ if (existing.totalDocs >= MAX_TRANSFERS_PER_DAY) {
5478
+ return Response.json(
5479
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
5480
+ { status: 429 }
5481
+ );
5482
+ }
5483
+ } catch {
5484
+ }
5485
+ const messages = await payload.find({
5486
+ collection: slugs.ticketMessages,
5487
+ where: {
5488
+ and: [
5489
+ { ticket: { equals: ticketId } },
5490
+ { isInternal: { equals: false } }
5491
+ ]
5492
+ },
5493
+ sort: "createdAt",
5494
+ limit: 200,
5495
+ depth: 1,
5496
+ overrideAccess: true
5497
+ });
5498
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5499
+ const ticketNumber = ticket.ticketNumber || `#${ticketId}`;
5500
+ const subject = ticket.subject || "Ticket";
5501
+ const statusLabel = statusToLabel(ticket.status);
5502
+ const timelineHtml = messages.docs.map((m) => {
5503
+ const date = m.createdAt ? new Date(m.createdAt).toLocaleString("fr-FR", { timeZone: "Europe/Paris" }) : "";
5504
+ const authorLabel = m.authorType === "admin" ? "\xC9quipe support" : m.authorClient && typeof m.authorClient === "object" ? `${m.authorClient.firstName || ""} ${m.authorClient.lastName || ""}`.trim() || "Client" : "Client";
5505
+ const bodyText = m.body || "";
5506
+ const bodyHtml = escapeHtml(bodyText).replace(/\n/g, "<br/>");
5507
+ let attachmentsHtml = "";
5508
+ if (includeAttachments && Array.isArray(m.attachments) && m.attachments.length > 0) {
5509
+ const items = m.attachments.map((a) => {
5510
+ const f = a?.file;
5511
+ if (!f || typeof f !== "object") return "";
5512
+ const url = f.url ? f.url.startsWith("http") ? f.url : `${baseUrl}${f.url}` : "";
5513
+ const name = f.filename || f.title || "pi\xE8ce jointe";
5514
+ if (!url) return `<li>${escapeHtml(name)} (lien indisponible)</li>`;
5515
+ return `<li><a href="${escapeHtml(url)}">${escapeHtml(name)}</a></li>`;
5516
+ }).filter(Boolean).join("");
5517
+ if (items) {
5518
+ attachmentsHtml = `<ul style="margin: 8px 0 0 0; padding-left: 20px; font-size: 13px; color: #475569;">${items}</ul>`;
5519
+ }
5520
+ }
5521
+ return `
5522
+ <div style="margin: 0 0 16px 0; padding: 14px 16px; border-left: 3px solid #e2e8f0; background: #f8fafc; border-radius: 0 8px 8px 0;">
5523
+ <div style="margin-bottom: 8px; font-size: 12px; color: #64748b;">
5524
+ <strong style="color: #0f172a;">${escapeHtml(authorLabel)}</strong>
5525
+ ${date ? ` &middot; ${escapeHtml(date)}` : ""}
5526
+ </div>
5527
+ <div style="font-size: 14px; line-height: 1.6; color: #1e293b; white-space: pre-wrap;">${bodyHtml || "<em>(message vide)</em>"}</div>
5528
+ ${attachmentsHtml}
5529
+ </div>
5530
+ `;
5531
+ }).join("");
5532
+ const customMessageHtml = customMessage ? `<div style="margin: 0 0 24px 0; padding: 16px; background: #fef9c3; border: 1px solid #fde047; border-radius: 8px;">
5533
+ <div style="font-size: 12px; font-weight: 700; text-transform: uppercase; color: #854d0e; margin-bottom: 6px;">Message du transmetteur</div>
5534
+ <div style="font-size: 14px; color: #422006; white-space: pre-wrap;">${escapeHtml(customMessage)}</div>
5535
+ </div>` : "";
5536
+ const settings = await readSupportSettings(payload).catch(() => null);
5537
+ const replyTo = settings?.email?.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
5538
+ const html = emailWrapper(
5539
+ `R\xE9capitulatif ticket ${ticketNumber}`,
5540
+ [
5541
+ emailParagraph(`Bonjour,`),
5542
+ emailParagraph(
5543
+ `Voici le r\xE9capitulatif du ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`
5544
+ ),
5545
+ customMessageHtml,
5546
+ `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 24px 0;">
5547
+ <tr><td style="padding: 4px 12px 4px 0; font-size: 12px; text-transform: uppercase; color: #64748b; font-weight: 700;">Sujet</td><td style="padding: 4px 0; font-size: 14px; color: #0f172a;">${escapeHtml(subject)}</td></tr>
5548
+ <tr><td style="padding: 4px 12px 4px 0; font-size: 12px; text-transform: uppercase; color: #64748b; font-weight: 700;">Statut</td><td style="padding: 4px 0; font-size: 14px; color: #0f172a;">${escapeHtml(statusLabel)}</td></tr>
5549
+ <tr><td style="padding: 4px 12px 4px 0; font-size: 12px; text-transform: uppercase; color: #64748b; font-weight: 700;">Num\xE9ro</td><td style="padding: 4px 0; font-size: 14px; color: #0f172a;">${escapeHtml(ticketNumber)}</td></tr>
5550
+ </table>`,
5551
+ `<h2 style="margin: 24px 0 12px 0; font-size: 16px; color: #0f172a;">Conversation</h2>`,
5552
+ timelineHtml || emailParagraph("<em>Aucun \xE9change \xE0 afficher.</em>"),
5553
+ baseUrl ? emailButton("Voir le ticket", `${baseUrl}/support/tickets/${ticketId}`) : "",
5554
+ emailParagraph(
5555
+ `<span style="font-size: 12px; color: #94a3b8;">Ce r\xE9capitulatif a \xE9t\xE9 transmis depuis l'espace support. Ne r\xE9pondez pas \xE0 cet email \u2014 utilisez le lien ci-dessus pour \xE9changer.</span>`
5556
+ )
5557
+ ].join("")
5558
+ );
5559
+ await payload.sendEmail({
5560
+ to: email,
5561
+ ...replyTo ? { replyTo } : {},
5562
+ subject: `R\xE9capitulatif support \u2014 ${ticketNumber} ${subject}`,
5563
+ html
5564
+ });
5565
+ try {
5566
+ await payload.create({
5567
+ collection: slugs.emailLogs,
5568
+ data: {
5569
+ status: "success",
5570
+ action: "transfer",
5571
+ senderEmail: req.user.collection === slugs.supportClients ? req.user.email : req.user.email,
5572
+ recipientEmail: email,
5573
+ subject: `Transfert ${ticketNumber}`,
5574
+ ticketNumber
5575
+ },
5576
+ overrideAccess: true
5577
+ });
5578
+ } catch {
5579
+ }
5580
+ return Response.json({ ok: true, sentTo: email });
5581
+ } catch (err) {
5582
+ const authResponse = handleAuthError(err);
5583
+ if (authResponse) return authResponse;
5584
+ console.error("[support/transfer-ticket] Error:", err);
5585
+ return Response.json({ error: "Internal server error" }, { status: 500 });
5586
+ }
5587
+ }
5588
+ };
5589
+ }
5590
+ function statusToLabel(status) {
5591
+ switch (status) {
5592
+ case "open":
5593
+ return "Ouvert";
5594
+ case "waiting_client":
5595
+ return "En attente du client";
5596
+ case "resolved":
5597
+ return "R\xE9solu";
5598
+ case "closed":
5599
+ return "Cl\xF4tur\xE9";
5600
+ case "escalated":
5601
+ return "Escalad\xE9";
5602
+ default:
5603
+ return String(status || "\u2014");
5604
+ }
5605
+ }
5606
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5607
+ function createInviteCollaboratorEndpoint(slugs) {
5608
+ return {
5609
+ path: "/support/tickets/:id/invite",
5610
+ method: "post",
5611
+ handler: async (req) => {
5612
+ try {
5613
+ if (!req.user) throw new AuthError("Authentication required", 401);
5614
+ const idRaw = req.routeParams?.id;
5615
+ if (!idRaw) return Response.json({ error: "missing-id" }, { status: 400 });
5616
+ const ticketId = Number(idRaw);
5617
+ if (Number.isNaN(ticketId)) return Response.json({ error: "invalid-id" }, { status: 400 });
5618
+ let body;
5619
+ try {
5620
+ body = await req.json();
5621
+ } catch {
5622
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
5623
+ }
5624
+ const email = (body.email || "").trim().toLowerCase();
5625
+ if (!email || !EMAIL_RE2.test(email) || email.length > 254) {
5626
+ return Response.json({ error: "Email invalide" }, { status: 400 });
5627
+ }
5628
+ const role = body.role === "collaborator" ? "collaborator" : "viewer";
5629
+ const payload = req.payload;
5630
+ const ticket = await payload.findByID({
5631
+ collection: slugs.tickets,
5632
+ id: ticketId,
5633
+ depth: 1,
5634
+ overrideAccess: true
5635
+ });
5636
+ if (!ticket) return Response.json({ error: "Ticket introuvable" }, { status: 404 });
5637
+ const isAdmin = req.user.collection === slugs.users;
5638
+ const ownerId = typeof ticket.client === "object" ? ticket.client?.id : ticket.client;
5639
+ const isOwner = req.user.collection === slugs.supportClients && ownerId === req.user.id;
5640
+ if (!isAdmin && !isOwner) {
5641
+ return Response.json({ error: "Forbidden" }, { status: 403 });
5642
+ }
5643
+ const ownerEmail = typeof ticket.client === "object" ? ticket.client?.email : void 0;
5644
+ if (ownerEmail && ownerEmail.toLowerCase() === email) {
5645
+ return Response.json({ error: "Ce client est d\xE9j\xE0 propri\xE9taire du ticket" }, { status: 400 });
5646
+ }
5647
+ let inviteeId = null;
5648
+ const existing = await payload.find({
5649
+ collection: slugs.supportClients,
5650
+ where: { email: { equals: email } },
5651
+ limit: 1,
5652
+ depth: 0,
5653
+ overrideAccess: true
5654
+ });
5655
+ if (existing.docs.length > 0) {
5656
+ inviteeId = existing.docs[0].id;
5657
+ } else {
5658
+ const tempPassword = crypto3.randomBytes(16).toString("hex");
5659
+ const created = await payload.create({
5660
+ collection: slugs.supportClients,
5661
+ data: {
5662
+ email,
5663
+ firstName: "Invit\xE9",
5664
+ lastName: email.split("@")[0]?.slice(0, 40) || "",
5665
+ company: "\u2014",
5666
+ password: tempPassword
5667
+ },
5668
+ overrideAccess: true,
5669
+ // Skip the "welcome" hook firing — let invitation email do the welcome instead
5670
+ context: { skipInviteEmail: true }
5671
+ });
5672
+ inviteeId = created.id;
5673
+ }
5674
+ if (!inviteeId) {
5675
+ return Response.json({ error: "Impossible de cr\xE9er le client invit\xE9" }, { status: 500 });
5676
+ }
5677
+ const dupe = await payload.find({
5678
+ collection: "ticket-collaborators",
5679
+ where: {
5680
+ and: [
5681
+ { ticket: { equals: ticketId } },
5682
+ { client: { equals: inviteeId } }
5683
+ ]
5684
+ },
5685
+ limit: 1,
5686
+ depth: 0,
5687
+ overrideAccess: true
5688
+ });
5689
+ const invitationToken = crypto3.randomBytes(24).toString("hex");
5690
+ if (dupe.docs.length > 0) {
5691
+ await payload.update({
5692
+ collection: "ticket-collaborators",
5693
+ id: dupe.docs[0].id,
5694
+ data: { role, invitationToken },
5695
+ overrideAccess: true
5696
+ });
5697
+ } else {
5698
+ await payload.create({
5699
+ collection: "ticket-collaborators",
5700
+ data: {
5701
+ ticket: ticketId,
5702
+ client: inviteeId,
5703
+ email,
5704
+ role,
5705
+ invitedBy: {
5706
+ relationTo: req.user.collection,
5707
+ value: req.user.id
5708
+ },
5709
+ invitationToken
5710
+ },
5711
+ overrideAccess: true
5712
+ });
5713
+ }
5714
+ const settings = await readSupportSettings(payload).catch(() => null);
5715
+ const replyTo = settings?.email?.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
5716
+ const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
5717
+ const ticketNumber = ticket.ticketNumber || `#${ticketId}`;
5718
+ const subject = ticket.subject || "Ticket";
5719
+ const inviteUrl = `${baseUrl}/support/tickets/${ticketId}?inviteToken=${invitationToken}`;
5720
+ const inviterLabel = req.user.collection === slugs.users ? "L'\xE9quipe support" : `${req.user.firstName || ""} ${req.user.lastName || ""}`.trim() || "Un coll\xE8gue";
5721
+ const roleLabel = role === "collaborator" ? "collaborateur (peut r\xE9pondre)" : "lecteur (consultation)";
5722
+ await payload.sendEmail({
5723
+ to: email,
5724
+ ...replyTo ? { replyTo } : {},
5725
+ subject: `Invitation au ticket ${ticketNumber}`,
5726
+ html: emailWrapper(
5727
+ `Invitation \xE0 un ticket support`,
5728
+ [
5729
+ emailParagraph(`Bonjour,`),
5730
+ emailParagraph(
5731
+ `<strong>${escapeHtml(inviterLabel)}</strong> vous invite \xE0 consulter le ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em>.`
5732
+ ),
5733
+ emailParagraph(`R\xF4le attribu\xE9 : <strong>${escapeHtml(roleLabel)}</strong>.`),
5734
+ baseUrl ? emailButton("Acc\xE9der au ticket", inviteUrl) : "",
5735
+ emailParagraph(
5736
+ `<span style="font-size: 12px; color: #94a3b8;">Si vous n'avez pas encore de compte support, vous serez invit\xE9 \xE0 d\xE9finir un mot de passe pour activer votre acc\xE8s.</span>`
5737
+ )
5738
+ ].join("")
5739
+ )
5740
+ });
5741
+ try {
5742
+ await payload.create({
5743
+ collection: slugs.emailLogs,
5744
+ data: {
5745
+ status: "success",
5746
+ action: "invite",
5747
+ senderEmail: req.user.email,
5748
+ recipientEmail: email,
5749
+ subject: `Invitation ${ticketNumber}`,
5750
+ ticketNumber
5751
+ },
5752
+ overrideAccess: true
5753
+ });
5754
+ } catch {
5755
+ }
5756
+ return Response.json({ ok: true, invitedTo: email, role });
5757
+ } catch (err) {
5758
+ const authResponse = handleAuthError(err);
5759
+ if (authResponse) return authResponse;
5760
+ console.error("[support/invite-collaborator] Error:", err);
5761
+ return Response.json({ error: "Internal server error" }, { status: 500 });
5762
+ }
5763
+ }
5764
+ };
5765
+ }
5766
+
5213
5767
  // src/endpoints/index.ts
5214
5768
  function createSupportEndpoints(slugs, options) {
5215
5769
  const f = options?.features;
5216
5770
  const endpoints = [
5217
5771
  createSearchEndpoint(slugs),
5772
+ createKbSearchEndpoint(slugs),
5218
5773
  createSettingsGetEndpoint(slugs),
5219
5774
  createSettingsPostEndpoint(slugs),
5220
5775
  createAdminStatsEndpoint(slugs),
@@ -5230,7 +5785,11 @@ function createSupportEndpoints(slugs, options) {
5230
5785
  createPurgeLogsEndpoint(slugs),
5231
5786
  createResendNotificationEndpoint(slugs),
5232
5787
  createUserPrefsGetEndpoint(slugs),
5233
- createUserPrefsPostEndpoint(slugs)
5788
+ createUserPrefsPostEndpoint(slugs),
5789
+ createEscalateEndpoint(slugs),
5790
+ createTicketFeedbackEndpoint(slugs),
5791
+ createTransferTicketEndpoint(slugs),
5792
+ createInviteCollaboratorEndpoint(slugs)
5234
5793
  ];
5235
5794
  if (!f || f.ai !== false) {
5236
5795
  endpoints.push(createAiEndpoint(slugs));
@@ -6290,7 +6849,9 @@ function createTicketsCollection(slugs, options) {
6290
6849
  options: [
6291
6850
  { label: "Ouvert", value: "open" },
6292
6851
  { label: "En attente client", value: "waiting_client" },
6293
- { label: "Resolu", value: "resolved" }
6852
+ { label: "Resolu", value: "resolved" },
6853
+ { label: "Ferme", value: "closed" },
6854
+ { label: "Escalade", value: "escalated" }
6294
6855
  ],
6295
6856
  admin: { width: "50%" }
6296
6857
  },
@@ -6509,9 +7070,30 @@ function createTicketsCollection(slugs, options) {
6509
7070
  if (req.user?.collection === slugs.supportClients) return true;
6510
7071
  return false;
6511
7072
  },
6512
- read: ({ req }) => {
7073
+ read: async ({ req }) => {
6513
7074
  if (req.user?.collection === slugs.users) return true;
6514
7075
  if (req.user?.collection === slugs.supportClients) {
7076
+ let collabTicketIds = [];
7077
+ try {
7078
+ const rows = await req.payload.find({
7079
+ collection: "ticket-collaborators",
7080
+ where: { client: { equals: req.user.id } },
7081
+ limit: 1e3,
7082
+ depth: 0,
7083
+ overrideAccess: true
7084
+ });
7085
+ collabTicketIds = rows.docs.map((r) => typeof r.ticket === "object" ? r.ticket?.id : r.ticket).filter((v) => v !== void 0 && v !== null);
7086
+ } catch {
7087
+ }
7088
+ if (collabTicketIds.length > 0) {
7089
+ const where = {
7090
+ or: [
7091
+ { client: { equals: req.user.id } },
7092
+ { id: { in: collabTicketIds } }
7093
+ ]
7094
+ };
7095
+ return where;
7096
+ }
6515
7097
  return { client: { equals: req.user.id } };
6516
7098
  }
6517
7099
  return false;
@@ -6795,6 +7377,15 @@ function createTicketMessagesCollection(slugs, options) {
6795
7377
  label: "Pieces jointes",
6796
7378
  fields: [{ name: "file", type: "upload", relationTo: slugs.media, required: true, label: "Fichier" }]
6797
7379
  },
7380
+ {
7381
+ name: "fromAlias",
7382
+ type: "text",
7383
+ label: "Alias d'exp\xE9diteur",
7384
+ admin: {
7385
+ description: "Si d\xE9fini, le message est envoy\xE9 au nom de cet alias plut\xF4t que de l'agent authentifi\xE9",
7386
+ position: "sidebar"
7387
+ }
7388
+ },
6798
7389
  { name: "isInternal", type: "checkbox", defaultValue: false, label: "Note interne", admin: { position: "sidebar" } },
6799
7390
  { name: "isSolution", type: "checkbox", defaultValue: false, label: "Reponse solution", admin: { position: "sidebar" } },
6800
7391
  { name: "skipNotification", type: "checkbox", defaultValue: false, label: "Sans notification", admin: { position: "sidebar", condition: (data) => data?.skipNotification === true } },
@@ -8298,6 +8889,22 @@ function createClientSummariesCollection(slugs) {
8298
8889
  admin: { readOnly: true }
8299
8890
  // Array of strings: "Hébergé chez OVH", "Site WordPress", etc.
8300
8891
  },
8892
+ {
8893
+ name: "nextActions",
8894
+ type: "array",
8895
+ label: "Prochaines actions sugg\xE9r\xE9es",
8896
+ admin: { description: "Liste des actions sugg\xE9r\xE9es par l'IA" },
8897
+ fields: [
8898
+ { name: "label", type: "text", required: true },
8899
+ {
8900
+ name: "priority",
8901
+ type: "select",
8902
+ options: ["now", "today", "this-week"],
8903
+ defaultValue: "today"
8904
+ },
8905
+ { name: "done", type: "checkbox", defaultValue: false }
8906
+ ]
8907
+ },
8301
8908
  // ── Stats ──
8302
8909
  {
8303
8910
  name: "ticketCount",
@@ -8355,6 +8962,191 @@ function createClientSummariesCollection(slugs) {
8355
8962
  };
8356
8963
  }
8357
8964
 
8965
+ // src/collections/TicketFeedback.ts
8966
+ function createAlertOnLowRating(slugs, notificationSlug) {
8967
+ return async ({ doc, operation, req }) => {
8968
+ if (operation !== "create") return doc;
8969
+ const rating = typeof doc.rating === "number" ? doc.rating : Number(doc.rating);
8970
+ if (!Number.isFinite(rating) || rating > 2) return doc;
8971
+ try {
8972
+ const ticketId = typeof doc.ticket === "object" ? doc.ticket?.id : doc.ticket;
8973
+ const ticketNumber = typeof doc.ticket === "object" ? doc.ticket?.ticketNumber : void 0;
8974
+ const ticketLabel = ticketNumber || (ticketId ? `#${ticketId}` : "?");
8975
+ await createAdminNotification(req.payload, {
8976
+ title: `Client mecontent : ${ticketLabel}`,
8977
+ message: `Note ${rating}/5${doc.comment ? ` \u2014 ${String(doc.comment).slice(0, 200)}` : ""}`,
8978
+ type: "satisfaction",
8979
+ link: ticketId ? `/admin/collections/${slugs.tickets}/${ticketId}` : void 0
8980
+ }, notificationSlug);
8981
+ } catch (err) {
8982
+ console.error("[support] Failed to alert on low rating:", err);
8983
+ }
8984
+ return doc;
8985
+ };
8986
+ }
8987
+ function createTicketFeedbackCollection(slugs, options) {
8988
+ const notificationSlug = options?.notificationSlug || "admin-notifications";
8989
+ return {
8990
+ slug: "ticket-feedback",
8991
+ labels: {
8992
+ singular: "Feedback ticket",
8993
+ plural: "Feedbacks tickets"
8994
+ },
8995
+ admin: {
8996
+ useAsTitle: "rating",
8997
+ group: "Support",
8998
+ defaultColumns: ["ticket", "rating", "client", "submittedFrom", "createdAt"]
8999
+ },
9000
+ access: {
9001
+ // Creation is gated by the dedicated endpoint (signed token / portal session).
9002
+ // We accept admins and authenticated support clients here; anonymous creation
9003
+ // must go through the endpoint which uses overrideAccess.
9004
+ create: ({ req }) => {
9005
+ if (req.user?.collection === slugs.users) return true;
9006
+ if (req.user?.collection === slugs.supportClients) return true;
9007
+ return false;
9008
+ },
9009
+ read: ({ req }) => {
9010
+ return req.user?.collection === slugs.users;
9011
+ },
9012
+ update: () => false,
9013
+ delete: ({ req }) => req.user?.collection === slugs.users
9014
+ },
9015
+ fields: [
9016
+ {
9017
+ name: "ticket",
9018
+ type: "relationship",
9019
+ relationTo: slugs.tickets,
9020
+ required: true,
9021
+ hasMany: false,
9022
+ unique: true,
9023
+ label: "Ticket",
9024
+ admin: { readOnly: true }
9025
+ },
9026
+ {
9027
+ name: "rating",
9028
+ type: "number",
9029
+ required: true,
9030
+ min: 1,
9031
+ max: 5,
9032
+ label: "Note (1-5)",
9033
+ admin: { description: "1 = tres insatisfait, 5 = tres satisfait" }
9034
+ },
9035
+ {
9036
+ name: "comment",
9037
+ type: "textarea",
9038
+ label: "Commentaire",
9039
+ admin: { description: "Commentaire optionnel du client" }
9040
+ },
9041
+ {
9042
+ name: "client",
9043
+ type: "relationship",
9044
+ relationTo: slugs.supportClients,
9045
+ label: "Client",
9046
+ admin: { readOnly: true }
9047
+ },
9048
+ {
9049
+ name: "submittedFrom",
9050
+ type: "select",
9051
+ defaultValue: "portal",
9052
+ label: "Source",
9053
+ options: [
9054
+ { label: "Portail", value: "portal" },
9055
+ { label: "Email", value: "email" },
9056
+ { label: "Admin", value: "admin" }
9057
+ ],
9058
+ admin: { readOnly: true }
9059
+ }
9060
+ ],
9061
+ hooks: {
9062
+ afterChange: [createAlertOnLowRating(slugs, notificationSlug)]
9063
+ },
9064
+ timestamps: true
9065
+ };
9066
+ }
9067
+
9068
+ // src/collections/TicketCollaborators.ts
9069
+ function createTicketCollaboratorsCollection(slugs) {
9070
+ return {
9071
+ slug: "ticket-collaborators",
9072
+ labels: {
9073
+ singular: "Collaborateur ticket",
9074
+ plural: "Collaborateurs tickets"
9075
+ },
9076
+ admin: {
9077
+ useAsTitle: "email",
9078
+ group: "Support",
9079
+ defaultColumns: ["email", "ticket", "client", "role", "invitedBy", "createdAt"]
9080
+ },
9081
+ access: {
9082
+ // Admins always; clients only see rows that reference them.
9083
+ read: ({ req }) => {
9084
+ if (req.user?.collection === slugs.users) return true;
9085
+ if (req.user?.collection === slugs.supportClients) {
9086
+ return { client: { equals: req.user.id } };
9087
+ }
9088
+ return false;
9089
+ },
9090
+ create: ({ req }) => !!req.user,
9091
+ update: () => false,
9092
+ delete: ({ req }) => req.user?.collection === slugs.users
9093
+ },
9094
+ fields: [
9095
+ {
9096
+ name: "ticket",
9097
+ type: "relationship",
9098
+ relationTo: slugs.tickets,
9099
+ required: true,
9100
+ hasMany: false,
9101
+ label: "Ticket"
9102
+ },
9103
+ {
9104
+ name: "client",
9105
+ type: "relationship",
9106
+ relationTo: slugs.supportClients,
9107
+ required: true,
9108
+ label: "Client invit\xE9"
9109
+ },
9110
+ {
9111
+ name: "email",
9112
+ type: "email",
9113
+ index: true,
9114
+ label: "Email invit\xE9",
9115
+ admin: { description: "Cache de l'email du client invit\xE9 au moment de l'invitation" }
9116
+ },
9117
+ {
9118
+ name: "role",
9119
+ type: "select",
9120
+ defaultValue: "viewer",
9121
+ options: [
9122
+ { label: "Lecteur", value: "viewer" },
9123
+ { label: "Collaborateur", value: "collaborator" }
9124
+ ],
9125
+ label: "R\xF4le"
9126
+ },
9127
+ {
9128
+ name: "invitedBy",
9129
+ type: "relationship",
9130
+ relationTo: [slugs.users, slugs.supportClients],
9131
+ required: true,
9132
+ label: "Invit\xE9 par"
9133
+ },
9134
+ {
9135
+ name: "acceptedAt",
9136
+ type: "date",
9137
+ label: "Accept\xE9 le"
9138
+ },
9139
+ {
9140
+ name: "invitationToken",
9141
+ type: "text",
9142
+ index: true,
9143
+ admin: { hidden: true }
9144
+ }
9145
+ ],
9146
+ timestamps: true
9147
+ };
9148
+ }
9149
+
8358
9150
  // src/plugin.ts
8359
9151
  function viewConfig(component, path) {
8360
9152
  return { Component: component, path };
@@ -8388,7 +9180,9 @@ function supportPlugin(config) {
8388
9180
  createCannedResponsesCollection(slugs),
8389
9181
  createTicketActivityLogCollection(slugs),
8390
9182
  createSatisfactionSurveysCollection(slugs),
8391
- createKnowledgeBaseCollection(slugs)
9183
+ createKnowledgeBaseCollection(slugs),
9184
+ createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
9185
+ createTicketCollaboratorsCollection(slugs)
8392
9186
  ];
8393
9187
  if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
8394
9188
  if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));
@@ -8464,6 +9258,7 @@ exports.createSatisfactionSurveysCollection = createSatisfactionSurveysCollectio
8464
9258
  exports.createSlaPoliciesCollection = createSlaPoliciesCollection;
8465
9259
  exports.createSupportClientsCollection = createSupportClientsCollection;
8466
9260
  exports.createTicketActivityLogCollection = createTicketActivityLogCollection;
9261
+ exports.createTicketCollaboratorsCollection = createTicketCollaboratorsCollection;
8467
9262
  exports.createTicketMessagesCollection = createTicketMessagesCollection;
8468
9263
  exports.createTicketStatusEmail = createTicketStatusEmail;
8469
9264
  exports.createTicketStatusesCollection = createTicketStatusesCollection;