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