@consilioweb/payload-support 0.9.11 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2969,9 +2969,36 @@ function createBillingEndpoint(slugs) {
2969
2969
  return Response.json({ error: "Missing from/to params" }, { status: 400 });
2970
2970
  }
2971
2971
  const projectId = url.searchParams.get("projectId");
2972
+ const toExclusive = new Date(to);
2973
+ toExclusive.setDate(toExclusive.getDate() + 1);
2974
+ const toExclusiveIso = toExclusive.toISOString();
2972
2975
  const ticketWhere = {
2973
- billable: { equals: true },
2974
- ...projectId ? { project: { equals: Number(projectId) } } : {}
2976
+ and: [
2977
+ { billable: { equals: true } },
2978
+ ...projectId ? [{ project: { equals: Number(projectId) } }] : [],
2979
+ {
2980
+ or: [
2981
+ {
2982
+ and: [
2983
+ { updatedAt: { greater_than_equal: from } },
2984
+ { updatedAt: { less_than: toExclusiveIso } }
2985
+ ]
2986
+ },
2987
+ {
2988
+ and: [
2989
+ { createdAt: { greater_than_equal: from } },
2990
+ { createdAt: { less_than: toExclusiveIso } }
2991
+ ]
2992
+ },
2993
+ {
2994
+ and: [
2995
+ { resolvedAt: { greater_than_equal: from } },
2996
+ { resolvedAt: { less_than: toExclusiveIso } }
2997
+ ]
2998
+ }
2999
+ ]
3000
+ }
3001
+ ]
2975
3002
  };
2976
3003
  const allTickets = [];
2977
3004
  let ticketPage = 1;
@@ -3024,8 +3051,8 @@ function createBillingEndpoint(slugs) {
3024
3051
  const projectGroups = /* @__PURE__ */ new Map();
3025
3052
  for (const ticket of allTickets) {
3026
3053
  const t = ticket;
3027
- const ticketEntries = entriesByTicket.get(t.id);
3028
- if (!ticketEntries || ticketEntries.length === 0) continue;
3054
+ const ticketEntries = entriesByTicket.get(t.id) || [];
3055
+ const hasNoTimeEntries = ticketEntries.length === 0;
3029
3056
  const project = typeof t.project === "object" && t.project ? { id: t.project.id, name: t.project.name || "Sans nom" } : null;
3030
3057
  const projectKey = project ? String(project.id) : "no-project";
3031
3058
  if (!projectGroups.has(projectKey)) {
@@ -3053,9 +3080,14 @@ function createBillingEndpoint(slugs) {
3053
3080
  id: t.id,
3054
3081
  ticketNumber: t.ticketNumber || "",
3055
3082
  subject: t.subject || "",
3083
+ status: t.status || "",
3056
3084
  entries: ticketEntries,
3057
3085
  totalMinutes: ticketTotalMinutes,
3058
- billedAmount
3086
+ billedAmount,
3087
+ hasNoTimeEntries,
3088
+ aiSummary: t.aiSummary || null,
3089
+ aiSummaryGeneratedAt: t.aiSummaryGeneratedAt || null,
3090
+ aiSummaryStatus: t.aiSummaryStatus || null
3059
3091
  });
3060
3092
  projectGroups.get(projectKey).totalMinutes += ticketTotalMinutes;
3061
3093
  if (billedAmount) projectGroups.get(projectKey).totalBilledAmount += billedAmount;
@@ -3063,7 +3095,16 @@ function createBillingEndpoint(slugs) {
3063
3095
  const groups = Array.from(projectGroups.values());
3064
3096
  const grandTotalMinutes = groups.reduce((sum, g) => sum + g.totalMinutes, 0);
3065
3097
  const grandTotalBilledAmount = groups.reduce((sum, g) => sum + g.totalBilledAmount, 0);
3066
- return new Response(JSON.stringify({ groups, grandTotalMinutes, grandTotalBilledAmount }), {
3098
+ const ticketsWithoutTime = groups.reduce(
3099
+ (sum, g) => sum + g.tickets.filter((t) => t.hasNoTimeEntries).length,
3100
+ 0
3101
+ );
3102
+ return new Response(JSON.stringify({
3103
+ groups,
3104
+ grandTotalMinutes,
3105
+ grandTotalBilledAmount,
3106
+ ticketsWithoutTime
3107
+ }), {
3067
3108
  headers: {
3068
3109
  "Content-Type": "application/json",
3069
3110
  "Cache-Control": "private, max-age=300, stale-while-revalidate=600"
@@ -3079,6 +3120,177 @@ function createBillingEndpoint(slugs) {
3079
3120
  };
3080
3121
  }
3081
3122
 
3123
+ // src/utils/generateTicketSynthesis.ts
3124
+ function getClient3(aiSettings) {
3125
+ const Anthropic = __require("@anthropic-ai/sdk").default;
3126
+ if (aiSettings.provider === "ollama") {
3127
+ const baseURL = process.env.OLLAMA_API_URL || "https://ollama.orkelis.app/v1";
3128
+ return new Anthropic({ apiKey: "ollama", baseURL });
3129
+ }
3130
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3131
+ }
3132
+ function getModel3(aiSettings) {
3133
+ return aiSettings.model || "claude-haiku-4-5-20251001";
3134
+ }
3135
+ async function generateTicketSynthesis(args) {
3136
+ const { payload, slugs, ticketId } = args;
3137
+ const settings = await readSupportSettings(payload);
3138
+ if (settings.ai.enableSynthesis === false) {
3139
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "skipped", reason: "synthesis disabled" };
3140
+ }
3141
+ const ticket = await payload.findByID({
3142
+ collection: slugs.tickets,
3143
+ id: ticketId,
3144
+ depth: 1,
3145
+ overrideAccess: true
3146
+ });
3147
+ if (!ticket) {
3148
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: "ticket not found" };
3149
+ }
3150
+ await payload.update({
3151
+ collection: slugs.tickets,
3152
+ id: ticketId,
3153
+ data: { aiSummaryStatus: "pending" },
3154
+ overrideAccess: true
3155
+ }).catch(() => {
3156
+ });
3157
+ const messagesResult = await payload.find({
3158
+ collection: slugs.ticketMessages,
3159
+ where: { ticket: { equals: ticketId } },
3160
+ sort: "createdAt",
3161
+ limit: 500,
3162
+ depth: 0,
3163
+ overrideAccess: true
3164
+ });
3165
+ const messages = messagesResult.docs;
3166
+ if (messages.length === 0) {
3167
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3168
+ await payload.update({
3169
+ collection: slugs.tickets,
3170
+ id: ticketId,
3171
+ data: {
3172
+ aiSummary: "(Aucun message dans ce ticket)",
3173
+ aiSummaryGeneratedAt: generatedAt,
3174
+ aiSummaryStatus: "done"
3175
+ },
3176
+ overrideAccess: true
3177
+ });
3178
+ return { summary: "(Aucun message dans ce ticket)", generatedAt, status: "done" };
3179
+ }
3180
+ const conversation = messages.map((m) => {
3181
+ const author = m.authorType === "admin" ? "Support" : "Client";
3182
+ const date = m.createdAt ? new Date(m.createdAt).toLocaleDateString("fr-FR", {
3183
+ day: "numeric",
3184
+ month: "short",
3185
+ hour: "2-digit",
3186
+ minute: "2-digit",
3187
+ timeZone: "Europe/Paris"
3188
+ }) : "";
3189
+ return `[${date}] ${author}: ${m.body || ""}`;
3190
+ }).join("\n\n");
3191
+ const clientObj = typeof ticket.client === "object" && ticket.client ? ticket.client : null;
3192
+ const clientCompany = clientObj?.company || "";
3193
+ const clientName = clientObj ? [clientObj.firstName, clientObj.lastName].filter(Boolean).join(" ") : "";
3194
+ const prompt = `Tu es un consultant technique qui prepare un recap factuel pour une facturation client.
3195
+
3196
+ Sujet du ticket : ${ticket.subject || "(sans sujet)"}
3197
+ Client : ${clientName || "Inconnu"}${clientCompany ? ` \u2014 ${clientCompany}` : ""}
3198
+
3199
+ Conversation complete du ticket :
3200
+ ${conversation}
3201
+
3202
+ 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.
3203
+
3204
+ Regles strictes :
3205
+ - Une puce = une action realisee, formulee en groupe nominal court (ex : "Diagnostic configuration DNS et authentification Mailchimp")
3206
+ - Pas de phrases completes, pas de "j'ai fait", pas de pronoms
3207
+ - Pas de salutations, pas d'introduction, pas de conclusion
3208
+ - Pas de markdown autre que les puces "- "
3209
+ - 5 a 10 puces maximum, ordonnees chronologiquement
3210
+ - Ne mentionne PAS le client par son nom dans les puces
3211
+ - Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
3212
+
3213
+ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
3214
+ const anthropic = getClient3(settings.ai);
3215
+ const model = getModel3(settings.ai);
3216
+ try {
3217
+ const res = await anthropic.messages.create({
3218
+ model,
3219
+ max_tokens: 600,
3220
+ messages: [{ role: "user", content: prompt }]
3221
+ });
3222
+ const summary = res.content[0]?.type === "text" ? res.content[0].text.trim() : "";
3223
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
3224
+ await payload.update({
3225
+ collection: slugs.tickets,
3226
+ id: ticketId,
3227
+ data: {
3228
+ aiSummary: summary,
3229
+ aiSummaryGeneratedAt: generatedAt,
3230
+ aiSummaryStatus: "done"
3231
+ },
3232
+ overrideAccess: true
3233
+ });
3234
+ return { summary, generatedAt, status: "done" };
3235
+ } catch (err) {
3236
+ const message = err instanceof Error ? err.message : String(err);
3237
+ await payload.update({
3238
+ collection: slugs.tickets,
3239
+ id: ticketId,
3240
+ data: { aiSummaryStatus: "error" },
3241
+ overrideAccess: true
3242
+ }).catch(() => {
3243
+ });
3244
+ return { summary: "", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), status: "error", reason: message };
3245
+ }
3246
+ }
3247
+
3248
+ // src/endpoints/ticket-synthesis.ts
3249
+ function createTicketSynthesisEndpoint(slugs) {
3250
+ return {
3251
+ path: "/support/ticket-synthesis",
3252
+ method: "post",
3253
+ handler: async (req) => {
3254
+ try {
3255
+ requireAdmin(req, slugs);
3256
+ const payload = req.payload;
3257
+ const url = new URL(req.url || "", "http://localhost");
3258
+ const ticketIdRaw = url.searchParams.get("ticketId");
3259
+ const force = url.searchParams.get("force") === "true";
3260
+ if (!ticketIdRaw) {
3261
+ return Response.json({ error: "ticketId required" }, { status: 400 });
3262
+ }
3263
+ const ticketId = Number(ticketIdRaw);
3264
+ if (Number.isNaN(ticketId)) {
3265
+ return Response.json({ error: "ticketId must be a number" }, { status: 400 });
3266
+ }
3267
+ if (!force) {
3268
+ const existing = await payload.findByID({
3269
+ collection: slugs.tickets,
3270
+ id: ticketId,
3271
+ depth: 0,
3272
+ overrideAccess: true
3273
+ });
3274
+ if (existing?.aiSummary && existing.aiSummaryStatus === "done") {
3275
+ return Response.json({
3276
+ summary: existing.aiSummary,
3277
+ generatedAt: existing.aiSummaryGeneratedAt,
3278
+ status: "cached"
3279
+ });
3280
+ }
3281
+ }
3282
+ const result = await generateTicketSynthesis({ payload, slugs, ticketId });
3283
+ return Response.json(result);
3284
+ } catch (err) {
3285
+ const authResponse = handleAuthError(err);
3286
+ if (authResponse) return authResponse;
3287
+ console.error("[support/ticket-synthesis] Error:", err);
3288
+ return Response.json({ error: "Internal server error" }, { status: 500 });
3289
+ }
3290
+ }
3291
+ };
3292
+ }
3293
+
3082
3294
  // src/endpoints/email-stats.ts
3083
3295
  function createEmailStatsEndpoint(slugs) {
3084
3296
  return {
@@ -5017,6 +5229,7 @@ function createSupportEndpoints(slugs, options) {
5017
5229
  if (!f || f.ai !== false) {
5018
5230
  endpoints.push(createAiEndpoint(slugs));
5019
5231
  endpoints.push(...createClientIntelligenceEndpoint(slugs));
5232
+ endpoints.push(createTicketSynthesisEndpoint(slugs));
5020
5233
  }
5021
5234
  if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs));
5022
5235
  if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs));
@@ -5673,6 +5886,32 @@ function createTrackSLA(slugs) {
5673
5886
  return doc;
5674
5887
  };
5675
5888
  }
5889
+ function createTrackAiSummaryOnResolve(slugs) {
5890
+ return async ({ doc, previousDoc, operation, req }) => {
5891
+ if (operation !== "update" || !previousDoc) return doc;
5892
+ const wasResolved = previousDoc.status === "resolved";
5893
+ const isResolved = doc.status === "resolved";
5894
+ if (wasResolved && !isResolved && doc.aiSummary) {
5895
+ try {
5896
+ await req.payload.update({
5897
+ collection: slugs.tickets,
5898
+ id: doc.id,
5899
+ data: { aiSummary: null, aiSummaryGeneratedAt: null, aiSummaryStatus: null },
5900
+ overrideAccess: true
5901
+ });
5902
+ } catch (err) {
5903
+ console.error("[support] Failed to clear ai summary on reopen:", err);
5904
+ }
5905
+ return doc;
5906
+ }
5907
+ if (!wasResolved && isResolved && !doc.aiSummary) {
5908
+ setImmediate(() => {
5909
+ generateTicketSynthesis({ payload: req.payload, slugs, ticketId: doc.id }).catch((err) => console.error("[support] Background ai synthesis failed:", err));
5910
+ });
5911
+ }
5912
+ return doc;
5913
+ };
5914
+ }
5676
5915
  function createLogTicketActivity(slugs) {
5677
5916
  return async ({ doc, previousDoc, operation, req }) => {
5678
5917
  if (operation !== "update" || !previousDoc) return doc;
@@ -6077,6 +6316,49 @@ function createTicketsCollection(slugs, options) {
6077
6316
  admin: { initCollapsed: true },
6078
6317
  fields: billingFields
6079
6318
  },
6319
+ // AI Synthesis collapsible — auto-filled when ticket is resolved
6320
+ {
6321
+ type: "collapsible",
6322
+ label: "Synthese IA",
6323
+ admin: {
6324
+ initCollapsed: true,
6325
+ description: "Recap factuel genere automatiquement au passage en resolu. Sert au copier-coller dans devis/factures."
6326
+ },
6327
+ fields: [
6328
+ {
6329
+ name: "aiSummary",
6330
+ type: "textarea",
6331
+ label: "Synthese",
6332
+ admin: {
6333
+ readOnly: true,
6334
+ rows: 8,
6335
+ description: "Vide tant que le ticket n'est pas resolu. Effacee si le ticket est reouvert."
6336
+ }
6337
+ },
6338
+ {
6339
+ type: "row",
6340
+ fields: [
6341
+ {
6342
+ name: "aiSummaryGeneratedAt",
6343
+ type: "date",
6344
+ label: "Genere le",
6345
+ admin: { readOnly: true, width: "50%", date: { displayFormat: "dd/MM/yyyy HH:mm" } }
6346
+ },
6347
+ {
6348
+ name: "aiSummaryStatus",
6349
+ type: "select",
6350
+ label: "Statut",
6351
+ options: [
6352
+ { label: "En cours", value: "pending" },
6353
+ { label: "Genere", value: "done" },
6354
+ { label: "Erreur", value: "error" }
6355
+ ],
6356
+ admin: { readOnly: true, width: "50%" }
6357
+ }
6358
+ ]
6359
+ }
6360
+ ]
6361
+ },
6080
6362
  // SLA & Delais
6081
6363
  {
6082
6364
  type: "collapsible",
@@ -6201,6 +6483,7 @@ function createTicketsCollection(slugs, options) {
6201
6483
  ],
6202
6484
  afterChange: [
6203
6485
  createTrackSLA(slugs),
6486
+ createTrackAiSummaryOnResolve(slugs),
6204
6487
  createAutoCalculateSLA(slugs),
6205
6488
  createAssignSlaDeadlines(slugs, notificationSlug),
6206
6489
  createCheckSlaOnResolve(slugs, notificationSlug),
@@ -8155,4 +8438,4 @@ function supportPlugin(config) {
8155
8438
  };
8156
8439
  }
8157
8440
 
8158
- 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, readSupportSettings, readUserPrefs, resolveSlugs, supportPlugin };
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 };
@@ -276,6 +276,138 @@
276
276
  color: #16a34a;
277
277
  }
278
278
 
279
+ // Warning banner — tickets without time
280
+ .warningBanner {
281
+ padding: 10px 16px;
282
+ border: 1px solid #f59e0b;
283
+ border-radius: 8px;
284
+ background: rgba(245, 158, 11, 0.08);
285
+ margin-bottom: 16px;
286
+ display: flex;
287
+ justify-content: space-between;
288
+ align-items: center;
289
+ flex-wrap: wrap;
290
+ gap: 12px;
291
+ font-size: 13px;
292
+ color: var(--theme-text);
293
+ }
294
+
295
+ .toggleLabel {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 6px;
299
+ cursor: pointer;
300
+ font-weight: 600;
301
+ user-select: none;
302
+ }
303
+
304
+ .noTimeBadge {
305
+ display: inline-block;
306
+ margin-left: 8px;
307
+ padding: 2px 8px;
308
+ border-radius: 999px;
309
+ background: rgba(245, 158, 11, 0.15);
310
+ color: #b45309;
311
+ font-size: 11px;
312
+ font-weight: 700;
313
+ white-space: nowrap;
314
+ }
315
+
316
+ .tableRowNoTime {
317
+ border-bottom: 1px solid var(--theme-elevation-200);
318
+ background: rgba(245, 158, 11, 0.04);
319
+ }
320
+
321
+ // AI summary toggle button
322
+ .summaryBtn {
323
+ display: inline-block;
324
+ margin-left: 6px;
325
+ padding: 1px 6px;
326
+ border-radius: 4px;
327
+ border: 1px solid var(--theme-elevation-300);
328
+ background: var(--theme-elevation-100);
329
+ color: var(--theme-elevation-500);
330
+ font-size: 10px;
331
+ font-weight: 600;
332
+ cursor: pointer;
333
+ vertical-align: middle;
334
+ transition: background-color 100ms, color 100ms;
335
+
336
+ &:hover {
337
+ background: #2563eb;
338
+ color: #fff;
339
+ border-color: #2563eb;
340
+ }
341
+ }
342
+
343
+ // AI summary expanded row
344
+ .summaryRow {
345
+ background: var(--theme-elevation-50);
346
+ border-bottom: 1px solid var(--theme-elevation-300);
347
+ }
348
+
349
+ .summaryCell {
350
+ padding: 12px 16px 16px;
351
+ }
352
+
353
+ .summaryHeader {
354
+ display: flex;
355
+ justify-content: space-between;
356
+ align-items: center;
357
+ margin-bottom: 8px;
358
+ flex-wrap: wrap;
359
+ gap: 8px;
360
+ }
361
+
362
+ .summaryActions {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 8px;
366
+ }
367
+
368
+ .summaryMeta {
369
+ font-size: 11px;
370
+ color: var(--theme-elevation-500);
371
+ }
372
+
373
+ .summaryAction {
374
+ padding: 4px 10px;
375
+ border-radius: 6px;
376
+ border: 1px solid var(--theme-elevation-300);
377
+ background: var(--theme-elevation-100);
378
+ color: var(--theme-text);
379
+ font-size: 12px;
380
+ font-weight: 600;
381
+ cursor: pointer;
382
+ transition: background-color 100ms;
383
+
384
+ &:hover { background: var(--theme-elevation-200); }
385
+ &:disabled { cursor: not-allowed; opacity: 0.5; }
386
+ }
387
+
388
+ .summaryText {
389
+ margin: 0;
390
+ padding: 10px 12px;
391
+ background: var(--theme-elevation-100);
392
+ border: 1px solid var(--theme-elevation-300);
393
+ border-radius: 6px;
394
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
395
+ font-size: 13px;
396
+ color: var(--theme-text);
397
+ white-space: pre-wrap;
398
+ word-break: break-word;
399
+ }
400
+
401
+ .summaryEmpty {
402
+ padding: 10px 12px;
403
+ background: var(--theme-elevation-100);
404
+ border: 1px dashed var(--theme-elevation-300);
405
+ border-radius: 6px;
406
+ color: var(--theme-elevation-500);
407
+ font-size: 12px;
408
+ font-style: italic;
409
+ }
410
+
279
411
  // Grand total
280
412
  .grandTotal {
281
413
  padding: 16px;