@consilioweb/payload-support 0.10.0 → 0.11.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/components/TicketConversation/locales/en.json +25 -1
- package/dist/components/TicketConversation/locales/fr.json +25 -1
- package/dist/index.cjs +799 -4
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +800 -6
- package/dist/styles/TicketDetail.module.scss +294 -0
- package/dist/views/TicketDetailView/client.cjs +455 -249
- package/dist/views/TicketDetailView/client.js +456 -250
- package/dist/views/TicketInboxView/client.cjs +10 -9
- package/dist/views/TicketInboxView/client.js +2 -1
- package/package.json +1 -1
- package/src/collections/ClientSummaries.ts +16 -0
- package/src/collections/TicketCollaborators.ts +93 -0
- package/src/collections/TicketFeedback.ts +116 -0
- package/src/collections/TicketMessages.ts +9 -0
- package/src/collections/Tickets.ts +31 -1
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/locales/en.json +25 -1
- package/src/components/TicketConversation/locales/fr.json +25 -1
- package/src/endpoints/escalate.ts +44 -0
- package/src/endpoints/index.ts +15 -0
- package/src/endpoints/invite-collaborator.ts +215 -0
- package/src/endpoints/kb-search.ts +156 -0
- package/src/endpoints/ticket-feedback.ts +104 -0
- package/src/endpoints/transfer-ticket.ts +248 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +4 -0
- package/src/portal/auth/ChatWidget.tsx +11 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
- package/src/portal/auth/dashboard/page.tsx +11 -2
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
- package/src/portal/auth/tickets/detail/page.tsx +122 -23
- package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
- package/src/portal/auth/tickets/new/page.tsx +203 -100
- package/src/portal/locales/en.json +5 -0
- package/src/portal/locales/fr.json +5 -0
- package/src/styles/TicketDetail.module.scss +294 -0
- package/src/types.ts +19 -0
- package/src/utils/slugs.ts +2 -0
- package/src/views/TicketDetailView/client.tsx +346 -89
- package/src/views/TicketInboxView/client.tsx +2 -1
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 ? ` · ${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;
|