@consilioweb/payload-support 0.9.10 → 0.9.12
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/hooks/useAI.cjs +24 -5
- package/dist/components/TicketConversation/hooks/useAI.js +24 -5
- package/dist/index.cjs +301 -13
- package/dist/index.js +301 -13
- package/dist/styles/BillingView.module.scss +132 -0
- package/dist/views/BillingView/client.cjs +287 -170
- package/dist/views/BillingView/client.js +287 -170
- package/dist/views/TicketDetailView/client.cjs +28 -6
- package/dist/views/TicketDetailView/client.js +28 -6
- package/package.json +1 -1
- package/src/collections/Tickets.ts +78 -0
- package/src/components/TicketConversation/hooks/useAI.ts +29 -5
- package/src/endpoints/ai.ts +13 -8
- package/src/endpoints/billing.ts +70 -9
- package/src/endpoints/index.ts +3 -0
- package/src/endpoints/ticket-synthesis.ts +67 -0
- package/src/styles/BillingView.module.scss +132 -0
- package/src/utils/generateTicketSynthesis.ts +178 -0
- package/src/views/BillingView/client.tsx +234 -102
- package/src/views/TicketDetailView/client.tsx +24 -6
|
@@ -77,22 +77,41 @@ function useAI(messages, client, ticketSubject, replyBody, setReplyBody, setRepl
|
|
|
77
77
|
};
|
|
78
78
|
const handleAiRewrite = async (style = "auto") => {
|
|
79
79
|
if (!replyBody.trim()) return;
|
|
80
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
81
|
+
const selectedText = sel?.toString().trim() || "";
|
|
82
|
+
const hasSelection = selectedText.length > 3;
|
|
83
|
+
const textToRewrite = hasSelection ? selectedText : replyBody;
|
|
80
84
|
setAiRewriting(true);
|
|
81
85
|
try {
|
|
82
86
|
const res = await fetch("/api/support/ai", {
|
|
83
87
|
method: "POST",
|
|
84
88
|
headers: { "Content-Type": "application/json" },
|
|
85
89
|
credentials: "include",
|
|
86
|
-
body: JSON.stringify({ action: "rewrite", text:
|
|
90
|
+
body: JSON.stringify({ action: "rewrite", text: textToRewrite, style })
|
|
87
91
|
});
|
|
88
92
|
if (res.ok) {
|
|
89
93
|
const data = await res.json();
|
|
90
94
|
const rewritten = data.rewritten || "";
|
|
91
95
|
if (rewritten) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
if (hasSelection && sel && sel.rangeCount > 0) {
|
|
97
|
+
const range = sel.getRangeAt(0);
|
|
98
|
+
range.deleteContents();
|
|
99
|
+
range.insertNode(document.createTextNode(rewritten));
|
|
100
|
+
const editorEl = replyEditorRef.current;
|
|
101
|
+
editorEl?.focus?.();
|
|
102
|
+
const rootEl = range.commonAncestorContainer.closest?.("[contenteditable]");
|
|
103
|
+
if (rootEl) {
|
|
104
|
+
const newHtml = rootEl.innerHTML;
|
|
105
|
+
const newText = rootEl.innerText?.trim() || "";
|
|
106
|
+
setReplyBody(newText);
|
|
107
|
+
setReplyHtml(newHtml);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
setReplyBody(rewritten);
|
|
111
|
+
setReplyHtml(rewritten.replace(/\n/g, "<br/>"));
|
|
112
|
+
if (replyEditorRef.current?.setContent) {
|
|
113
|
+
replyEditorRef.current.setContent(rewritten.replace(/\n/g, "<br/>"));
|
|
114
|
+
}
|
|
96
115
|
}
|
|
97
116
|
}
|
|
98
117
|
}
|
|
@@ -76,22 +76,41 @@ function useAI(messages, client, ticketSubject, replyBody, setReplyBody, setRepl
|
|
|
76
76
|
};
|
|
77
77
|
const handleAiRewrite = async (style = "auto") => {
|
|
78
78
|
if (!replyBody.trim()) return;
|
|
79
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
80
|
+
const selectedText = sel?.toString().trim() || "";
|
|
81
|
+
const hasSelection = selectedText.length > 3;
|
|
82
|
+
const textToRewrite = hasSelection ? selectedText : replyBody;
|
|
79
83
|
setAiRewriting(true);
|
|
80
84
|
try {
|
|
81
85
|
const res = await fetch("/api/support/ai", {
|
|
82
86
|
method: "POST",
|
|
83
87
|
headers: { "Content-Type": "application/json" },
|
|
84
88
|
credentials: "include",
|
|
85
|
-
body: JSON.stringify({ action: "rewrite", text:
|
|
89
|
+
body: JSON.stringify({ action: "rewrite", text: textToRewrite, style })
|
|
86
90
|
});
|
|
87
91
|
if (res.ok) {
|
|
88
92
|
const data = await res.json();
|
|
89
93
|
const rewritten = data.rewritten || "";
|
|
90
94
|
if (rewritten) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
if (hasSelection && sel && sel.rangeCount > 0) {
|
|
96
|
+
const range = sel.getRangeAt(0);
|
|
97
|
+
range.deleteContents();
|
|
98
|
+
range.insertNode(document.createTextNode(rewritten));
|
|
99
|
+
const editorEl = replyEditorRef.current;
|
|
100
|
+
editorEl?.focus?.();
|
|
101
|
+
const rootEl = range.commonAncestorContainer.closest?.("[contenteditable]");
|
|
102
|
+
if (rootEl) {
|
|
103
|
+
const newHtml = rootEl.innerHTML;
|
|
104
|
+
const newText = rootEl.innerText?.trim() || "";
|
|
105
|
+
setReplyBody(newText);
|
|
106
|
+
setReplyHtml(newHtml);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
setReplyBody(rewritten);
|
|
110
|
+
setReplyHtml(rewritten.replace(/\n/g, "<br/>"));
|
|
111
|
+
if (replyEditorRef.current?.setContent) {
|
|
112
|
+
replyEditorRef.current.setContent(rewritten.replace(/\n/g, "<br/>"));
|
|
113
|
+
}
|
|
95
114
|
}
|
|
96
115
|
}
|
|
97
116
|
}
|
package/dist/index.cjs
CHANGED
|
@@ -283,15 +283,20 @@ R\xE9dige une r\xE9ponse appropri\xE9e au dernier message du client. Sois concis
|
|
|
283
283
|
const { text, style } = body;
|
|
284
284
|
if (!text?.trim()) return Response.json({ error: "text required" }, { status: 400 });
|
|
285
285
|
const styleInstructions = {
|
|
286
|
-
auto: "Garde le
|
|
287
|
-
tutoyer: "Utilise le tutoiement. Si le texte vouvoie, convertis en tutoiement.",
|
|
288
|
-
vouvoyer: "Utilise le vouvoiement. Si le texte tutoie, convertis en vouvoiement.",
|
|
289
|
-
formel:
|
|
290
|
-
court: "
|
|
291
|
-
amical:
|
|
286
|
+
auto: { tone: "Garde le ton actuel (neutre professionnel).", person: "IMPORTANT: Pr\xE9serve EXACTEMENT le tutoiement ou vouvoiement du texte original." },
|
|
287
|
+
tutoyer: { tone: "Ton d\xE9contract\xE9 et direct, mais correct.", person: 'CRITIQUE: Utilise IMP\xC9RATIVEMENT le tutoiement partout (tu, ton, te, toi). Si le texte vouvoie, convertis TOUT en tutoiement. Exemple: "vous pouvez" \u2192 "tu peux", "votre" \u2192 "ton".' },
|
|
288
|
+
vouvoyer: { tone: "Ton neutre et poli.", person: "CRITIQUE: Utilise IMP\xC9RATIVEMENT le vouvoiement partout (vous, votre, etc). Si le texte tutoie, convertis TOUT en vouvoiement." },
|
|
289
|
+
formel: { tone: "Ton formel et institutionnel.", person: "Utilise le vouvoiement partout." },
|
|
290
|
+
court: { tone: "Style concis et direct, phrases courtes, aller \xE0 l'essentiel.", person: "Pr\xE9serve le tutoiement/vouvoiement du texte original." },
|
|
291
|
+
amical: { tone: "Ton chaleureux, amical, sympathique, avec un peu de cordialit\xE9.", person: "CRITIQUE: Utilise IMP\xC9RATIVEMENT le tutoiement partout. Convertis le vouvoiement en tutoiement." }
|
|
292
292
|
};
|
|
293
293
|
const styleGuide = styleInstructions[style || "auto"] || styleInstructions.auto;
|
|
294
|
-
const prompt = `
|
|
294
|
+
const prompt = `Reformule le texte ci-dessous en corrigeant les fautes d'orthographe/grammaire. Ne change pas le fond du message, am\xE9liore uniquement la forme.
|
|
295
|
+
|
|
296
|
+
TON REQUIS: ${styleGuide.tone}
|
|
297
|
+
FORME REQUISE: ${styleGuide.person}
|
|
298
|
+
|
|
299
|
+
R\xE9ponds UNIQUEMENT avec le texte reformul\xE9, sans commentaire ni explication.
|
|
295
300
|
|
|
296
301
|
Texte original :
|
|
297
302
|
${text}`;
|
|
@@ -2970,9 +2975,36 @@ function createBillingEndpoint(slugs) {
|
|
|
2970
2975
|
return Response.json({ error: "Missing from/to params" }, { status: 400 });
|
|
2971
2976
|
}
|
|
2972
2977
|
const projectId = url.searchParams.get("projectId");
|
|
2978
|
+
const toExclusive = new Date(to);
|
|
2979
|
+
toExclusive.setDate(toExclusive.getDate() + 1);
|
|
2980
|
+
const toExclusiveIso = toExclusive.toISOString();
|
|
2973
2981
|
const ticketWhere = {
|
|
2974
|
-
|
|
2975
|
-
|
|
2982
|
+
and: [
|
|
2983
|
+
{ billable: { equals: true } },
|
|
2984
|
+
...projectId ? [{ project: { equals: Number(projectId) } }] : [],
|
|
2985
|
+
{
|
|
2986
|
+
or: [
|
|
2987
|
+
{
|
|
2988
|
+
and: [
|
|
2989
|
+
{ updatedAt: { greater_than_equal: from } },
|
|
2990
|
+
{ updatedAt: { less_than: toExclusiveIso } }
|
|
2991
|
+
]
|
|
2992
|
+
},
|
|
2993
|
+
{
|
|
2994
|
+
and: [
|
|
2995
|
+
{ createdAt: { greater_than_equal: from } },
|
|
2996
|
+
{ createdAt: { less_than: toExclusiveIso } }
|
|
2997
|
+
]
|
|
2998
|
+
},
|
|
2999
|
+
{
|
|
3000
|
+
and: [
|
|
3001
|
+
{ resolvedAt: { greater_than_equal: from } },
|
|
3002
|
+
{ resolvedAt: { less_than: toExclusiveIso } }
|
|
3003
|
+
]
|
|
3004
|
+
}
|
|
3005
|
+
]
|
|
3006
|
+
}
|
|
3007
|
+
]
|
|
2976
3008
|
};
|
|
2977
3009
|
const allTickets = [];
|
|
2978
3010
|
let ticketPage = 1;
|
|
@@ -3025,8 +3057,8 @@ function createBillingEndpoint(slugs) {
|
|
|
3025
3057
|
const projectGroups = /* @__PURE__ */ new Map();
|
|
3026
3058
|
for (const ticket of allTickets) {
|
|
3027
3059
|
const t = ticket;
|
|
3028
|
-
const ticketEntries = entriesByTicket.get(t.id);
|
|
3029
|
-
|
|
3060
|
+
const ticketEntries = entriesByTicket.get(t.id) || [];
|
|
3061
|
+
const hasNoTimeEntries = ticketEntries.length === 0;
|
|
3030
3062
|
const project = typeof t.project === "object" && t.project ? { id: t.project.id, name: t.project.name || "Sans nom" } : null;
|
|
3031
3063
|
const projectKey = project ? String(project.id) : "no-project";
|
|
3032
3064
|
if (!projectGroups.has(projectKey)) {
|
|
@@ -3054,9 +3086,14 @@ function createBillingEndpoint(slugs) {
|
|
|
3054
3086
|
id: t.id,
|
|
3055
3087
|
ticketNumber: t.ticketNumber || "",
|
|
3056
3088
|
subject: t.subject || "",
|
|
3089
|
+
status: t.status || "",
|
|
3057
3090
|
entries: ticketEntries,
|
|
3058
3091
|
totalMinutes: ticketTotalMinutes,
|
|
3059
|
-
billedAmount
|
|
3092
|
+
billedAmount,
|
|
3093
|
+
hasNoTimeEntries,
|
|
3094
|
+
aiSummary: t.aiSummary || null,
|
|
3095
|
+
aiSummaryGeneratedAt: t.aiSummaryGeneratedAt || null,
|
|
3096
|
+
aiSummaryStatus: t.aiSummaryStatus || null
|
|
3060
3097
|
});
|
|
3061
3098
|
projectGroups.get(projectKey).totalMinutes += ticketTotalMinutes;
|
|
3062
3099
|
if (billedAmount) projectGroups.get(projectKey).totalBilledAmount += billedAmount;
|
|
@@ -3064,7 +3101,16 @@ function createBillingEndpoint(slugs) {
|
|
|
3064
3101
|
const groups = Array.from(projectGroups.values());
|
|
3065
3102
|
const grandTotalMinutes = groups.reduce((sum, g) => sum + g.totalMinutes, 0);
|
|
3066
3103
|
const grandTotalBilledAmount = groups.reduce((sum, g) => sum + g.totalBilledAmount, 0);
|
|
3067
|
-
|
|
3104
|
+
const ticketsWithoutTime = groups.reduce(
|
|
3105
|
+
(sum, g) => sum + g.tickets.filter((t) => t.hasNoTimeEntries).length,
|
|
3106
|
+
0
|
|
3107
|
+
);
|
|
3108
|
+
return new Response(JSON.stringify({
|
|
3109
|
+
groups,
|
|
3110
|
+
grandTotalMinutes,
|
|
3111
|
+
grandTotalBilledAmount,
|
|
3112
|
+
ticketsWithoutTime
|
|
3113
|
+
}), {
|
|
3068
3114
|
headers: {
|
|
3069
3115
|
"Content-Type": "application/json",
|
|
3070
3116
|
"Cache-Control": "private, max-age=300, stale-while-revalidate=600"
|
|
@@ -3080,6 +3126,177 @@ function createBillingEndpoint(slugs) {
|
|
|
3080
3126
|
};
|
|
3081
3127
|
}
|
|
3082
3128
|
|
|
3129
|
+
// src/utils/generateTicketSynthesis.ts
|
|
3130
|
+
function getClient3(aiSettings) {
|
|
3131
|
+
const Anthropic = __require("@anthropic-ai/sdk").default;
|
|
3132
|
+
if (aiSettings.provider === "ollama") {
|
|
3133
|
+
const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
|
|
3134
|
+
return new Anthropic({ apiKey: "ollama", baseURL });
|
|
3135
|
+
}
|
|
3136
|
+
return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
3137
|
+
}
|
|
3138
|
+
function getModel3(aiSettings) {
|
|
3139
|
+
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
3140
|
+
}
|
|
3141
|
+
async function generateTicketSynthesis(args) {
|
|
3142
|
+
const { payload, slugs, ticketId } = args;
|
|
3143
|
+
const settings = await readSupportSettings(payload);
|
|
3144
|
+
if (settings.ai.enableSynthesis === false) {
|
|
3145
|
+
return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
|
|
3146
|
+
}
|
|
3147
|
+
const ticket = await payload.findByID({
|
|
3148
|
+
collection: slugs.tickets,
|
|
3149
|
+
id: ticketId,
|
|
3150
|
+
depth: 1,
|
|
3151
|
+
overrideAccess: true
|
|
3152
|
+
});
|
|
3153
|
+
if (!ticket) {
|
|
3154
|
+
return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
|
|
3155
|
+
}
|
|
3156
|
+
await payload.update({
|
|
3157
|
+
collection: slugs.tickets,
|
|
3158
|
+
id: ticketId,
|
|
3159
|
+
data: { aiSummaryStatus: "pending" },
|
|
3160
|
+
overrideAccess: true
|
|
3161
|
+
}).catch(() => {
|
|
3162
|
+
});
|
|
3163
|
+
const messagesResult = await payload.find({
|
|
3164
|
+
collection: slugs.ticketMessages,
|
|
3165
|
+
where: { ticket: { equals: ticketId } },
|
|
3166
|
+
sort: "createdAt",
|
|
3167
|
+
limit: 500,
|
|
3168
|
+
depth: 0,
|
|
3169
|
+
overrideAccess: true
|
|
3170
|
+
});
|
|
3171
|
+
const messages = messagesResult.docs;
|
|
3172
|
+
if (messages.length === 0) {
|
|
3173
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3174
|
+
await payload.update({
|
|
3175
|
+
collection: slugs.tickets,
|
|
3176
|
+
id: ticketId,
|
|
3177
|
+
data: {
|
|
3178
|
+
aiSummary: "(Aucun message dans ce ticket)",
|
|
3179
|
+
aiSummaryGeneratedAt: generatedAt,
|
|
3180
|
+
aiSummaryStatus: "done"
|
|
3181
|
+
},
|
|
3182
|
+
overrideAccess: true
|
|
3183
|
+
});
|
|
3184
|
+
return { summary: "(Aucun message dans ce ticket)", generatedAt, status: "done" };
|
|
3185
|
+
}
|
|
3186
|
+
const conversation = messages.map((m) => {
|
|
3187
|
+
const author = m.authorType === "admin" ? "Support" : "Client";
|
|
3188
|
+
const date = m.createdAt ? new Date(m.createdAt).toLocaleDateString("fr-FR", {
|
|
3189
|
+
day: "numeric",
|
|
3190
|
+
month: "short",
|
|
3191
|
+
hour: "2-digit",
|
|
3192
|
+
minute: "2-digit",
|
|
3193
|
+
timeZone: "Europe/Paris"
|
|
3194
|
+
}) : "";
|
|
3195
|
+
return `[${date}] ${author}: ${m.body || ""}`;
|
|
3196
|
+
}).join("\n\n");
|
|
3197
|
+
const clientObj = typeof ticket.client === "object" && ticket.client ? ticket.client : null;
|
|
3198
|
+
const clientCompany = clientObj?.company || "";
|
|
3199
|
+
const clientName = clientObj ? [clientObj.firstName, clientObj.lastName].filter(Boolean).join(" ") : "";
|
|
3200
|
+
const prompt = `Tu es un consultant technique qui prepare un recap factuel pour une facturation client.
|
|
3201
|
+
|
|
3202
|
+
Sujet du ticket : ${ticket.subject || "(sans sujet)"}
|
|
3203
|
+
Client : ${clientName || "Inconnu"}${clientCompany ? ` \u2014 ${clientCompany}` : ""}
|
|
3204
|
+
|
|
3205
|
+
Conversation complete du ticket :
|
|
3206
|
+
${conversation}
|
|
3207
|
+
|
|
3208
|
+
Genere un recap factuel sous forme d'une liste a puces courtes et actionnables, decrivant CE QUI A ETE FAIT cote support pendant ce ticket. C'est destine a etre colle dans un devis ou une facture.
|
|
3209
|
+
|
|
3210
|
+
Regles strictes :
|
|
3211
|
+
- Une puce = une action realisee, formulee en groupe nominal court (ex : "Diagnostic configuration DNS et authentification Mailchimp")
|
|
3212
|
+
- Pas de phrases completes, pas de "j'ai fait", pas de pronoms
|
|
3213
|
+
- Pas de salutations, pas d'introduction, pas de conclusion
|
|
3214
|
+
- Pas de markdown autre que les puces "- "
|
|
3215
|
+
- 5 a 10 puces maximum, ordonnees chronologiquement
|
|
3216
|
+
- Ne mentionne PAS le client par son nom dans les puces
|
|
3217
|
+
- Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
|
|
3218
|
+
|
|
3219
|
+
Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
|
|
3220
|
+
const anthropic = getClient3(settings.ai);
|
|
3221
|
+
const model = getModel3(settings.ai);
|
|
3222
|
+
try {
|
|
3223
|
+
const res = await anthropic.messages.create({
|
|
3224
|
+
model,
|
|
3225
|
+
max_tokens: 600,
|
|
3226
|
+
messages: [{ role: "user", content: prompt }]
|
|
3227
|
+
});
|
|
3228
|
+
const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
|
|
3229
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3230
|
+
await payload.update({
|
|
3231
|
+
collection: slugs.tickets,
|
|
3232
|
+
id: ticketId,
|
|
3233
|
+
data: {
|
|
3234
|
+
aiSummary: summary,
|
|
3235
|
+
aiSummaryGeneratedAt: generatedAt,
|
|
3236
|
+
aiSummaryStatus: "done"
|
|
3237
|
+
},
|
|
3238
|
+
overrideAccess: true
|
|
3239
|
+
});
|
|
3240
|
+
return { summary, generatedAt, status: "done" };
|
|
3241
|
+
} catch (err) {
|
|
3242
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3243
|
+
await payload.update({
|
|
3244
|
+
collection: slugs.tickets,
|
|
3245
|
+
id: ticketId,
|
|
3246
|
+
data: { aiSummaryStatus: "error" },
|
|
3247
|
+
overrideAccess: true
|
|
3248
|
+
}).catch(() => {
|
|
3249
|
+
});
|
|
3250
|
+
return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: message };
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// src/endpoints/ticket-synthesis.ts
|
|
3255
|
+
function createTicketSynthesisEndpoint(slugs) {
|
|
3256
|
+
return {
|
|
3257
|
+
path: "/support/ticket-synthesis",
|
|
3258
|
+
method: "post",
|
|
3259
|
+
handler: async (req) => {
|
|
3260
|
+
try {
|
|
3261
|
+
requireAdmin(req, slugs);
|
|
3262
|
+
const payload = req.payload;
|
|
3263
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
3264
|
+
const ticketIdRaw = url.searchParams.get("ticketId");
|
|
3265
|
+
const force = url.searchParams.get("force") === "true";
|
|
3266
|
+
if (!ticketIdRaw) {
|
|
3267
|
+
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
3268
|
+
}
|
|
3269
|
+
const ticketId = Number(ticketIdRaw);
|
|
3270
|
+
if (Number.isNaN(ticketId)) {
|
|
3271
|
+
return Response.json({ error: "ticketId must be a number" }, { status: 400 });
|
|
3272
|
+
}
|
|
3273
|
+
if (!force) {
|
|
3274
|
+
const existing = await payload.findByID({
|
|
3275
|
+
collection: slugs.tickets,
|
|
3276
|
+
id: ticketId,
|
|
3277
|
+
depth: 0,
|
|
3278
|
+
overrideAccess: true
|
|
3279
|
+
});
|
|
3280
|
+
if (existing?.aiSummary && existing.aiSummaryStatus === "done") {
|
|
3281
|
+
return Response.json({
|
|
3282
|
+
summary: existing.aiSummary,
|
|
3283
|
+
generatedAt: existing.aiSummaryGeneratedAt,
|
|
3284
|
+
status: "cached"
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
const result = await generateTicketSynthesis({ payload, slugs, ticketId });
|
|
3289
|
+
return Response.json(result);
|
|
3290
|
+
} catch (err) {
|
|
3291
|
+
const authResponse = handleAuthError(err);
|
|
3292
|
+
if (authResponse) return authResponse;
|
|
3293
|
+
console.error("[support/ticket-synthesis] Error:", err);
|
|
3294
|
+
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3083
3300
|
// src/endpoints/email-stats.ts
|
|
3084
3301
|
function createEmailStatsEndpoint(slugs) {
|
|
3085
3302
|
return {
|
|
@@ -5018,6 +5235,7 @@ function createSupportEndpoints(slugs, options) {
|
|
|
5018
5235
|
if (!f || f.ai !== false) {
|
|
5019
5236
|
endpoints.push(createAiEndpoint(slugs));
|
|
5020
5237
|
endpoints.push(...createClientIntelligenceEndpoint(slugs));
|
|
5238
|
+
endpoints.push(createTicketSynthesisEndpoint(slugs));
|
|
5021
5239
|
}
|
|
5022
5240
|
if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs));
|
|
5023
5241
|
if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs));
|
|
@@ -5674,6 +5892,32 @@ function createTrackSLA(slugs) {
|
|
|
5674
5892
|
return doc;
|
|
5675
5893
|
};
|
|
5676
5894
|
}
|
|
5895
|
+
function createTrackAiSummaryOnResolve(slugs) {
|
|
5896
|
+
return async ({ doc, previousDoc, operation, req }) => {
|
|
5897
|
+
if (operation !== "update" || !previousDoc) return doc;
|
|
5898
|
+
const wasResolved = previousDoc.status === "resolved";
|
|
5899
|
+
const isResolved = doc.status === "resolved";
|
|
5900
|
+
if (wasResolved && !isResolved && doc.aiSummary) {
|
|
5901
|
+
try {
|
|
5902
|
+
await req.payload.update({
|
|
5903
|
+
collection: slugs.tickets,
|
|
5904
|
+
id: doc.id,
|
|
5905
|
+
data: { aiSummary: null, aiSummaryGeneratedAt: null, aiSummaryStatus: null },
|
|
5906
|
+
overrideAccess: true
|
|
5907
|
+
});
|
|
5908
|
+
} catch (err) {
|
|
5909
|
+
console.error("[support] Failed to clear ai summary on reopen:", err);
|
|
5910
|
+
}
|
|
5911
|
+
return doc;
|
|
5912
|
+
}
|
|
5913
|
+
if (!wasResolved && isResolved && !doc.aiSummary) {
|
|
5914
|
+
setImmediate(() => {
|
|
5915
|
+
generateTicketSynthesis({ payload: req.payload, slugs, ticketId: doc.id }).catch((err) => console.error("[support] Background ai synthesis failed:", err));
|
|
5916
|
+
});
|
|
5917
|
+
}
|
|
5918
|
+
return doc;
|
|
5919
|
+
};
|
|
5920
|
+
}
|
|
5677
5921
|
function createLogTicketActivity(slugs) {
|
|
5678
5922
|
return async ({ doc, previousDoc, operation, req }) => {
|
|
5679
5923
|
if (operation !== "update" || !previousDoc) return doc;
|
|
@@ -6078,6 +6322,49 @@ function createTicketsCollection(slugs, options) {
|
|
|
6078
6322
|
admin: { initCollapsed: true },
|
|
6079
6323
|
fields: billingFields
|
|
6080
6324
|
},
|
|
6325
|
+
// AI Synthesis collapsible — auto-filled when ticket is resolved
|
|
6326
|
+
{
|
|
6327
|
+
type: "collapsible",
|
|
6328
|
+
label: "Synthese IA",
|
|
6329
|
+
admin: {
|
|
6330
|
+
initCollapsed: true,
|
|
6331
|
+
description: "Recap factuel genere automatiquement au passage en resolu. Sert au copier-coller dans devis/factures."
|
|
6332
|
+
},
|
|
6333
|
+
fields: [
|
|
6334
|
+
{
|
|
6335
|
+
name: "aiSummary",
|
|
6336
|
+
type: "textarea",
|
|
6337
|
+
label: "Synthese",
|
|
6338
|
+
admin: {
|
|
6339
|
+
readOnly: true,
|
|
6340
|
+
rows: 8,
|
|
6341
|
+
description: "Vide tant que le ticket n'est pas resolu. Effacee si le ticket est reouvert."
|
|
6342
|
+
}
|
|
6343
|
+
},
|
|
6344
|
+
{
|
|
6345
|
+
type: "row",
|
|
6346
|
+
fields: [
|
|
6347
|
+
{
|
|
6348
|
+
name: "aiSummaryGeneratedAt",
|
|
6349
|
+
type: "date",
|
|
6350
|
+
label: "Genere le",
|
|
6351
|
+
admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } }
|
|
6352
|
+
},
|
|
6353
|
+
{
|
|
6354
|
+
name: "aiSummaryStatus",
|
|
6355
|
+
type: "select",
|
|
6356
|
+
label: "Statut",
|
|
6357
|
+
options: [
|
|
6358
|
+
{ label: "En cours", value: "pending" },
|
|
6359
|
+
{ label: "Genere", value: "done" },
|
|
6360
|
+
{ label: "Erreur", value: "error" }
|
|
6361
|
+
],
|
|
6362
|
+
admin: { readOnly: true, width: "50%" }
|
|
6363
|
+
}
|
|
6364
|
+
]
|
|
6365
|
+
}
|
|
6366
|
+
]
|
|
6367
|
+
},
|
|
6081
6368
|
// SLA & Delais
|
|
6082
6369
|
{
|
|
6083
6370
|
type: "collapsible",
|
|
@@ -6202,6 +6489,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
6202
6489
|
],
|
|
6203
6490
|
afterChange: [
|
|
6204
6491
|
createTrackSLA(slugs),
|
|
6492
|
+
createTrackAiSummaryOnResolve(slugs),
|
|
6205
6493
|
createAutoCalculateSLA(slugs),
|
|
6206
6494
|
createAssignSlaDeadlines(slugs, notificationSlug),
|
|
6207
6495
|
createCheckSlaOnResolve(slugs, notificationSlug),
|