@consilioweb/payload-support 0.15.0 → 0.16.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/components/ActionPanels.cjs +72 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
- package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
- package/dist/components/TicketConversation/components/QuickActions.js +22 -1
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
- package/dist/components/TicketConversation/index.cjs +32 -2
- package/dist/components/TicketConversation/index.js +33 -3
- package/dist/index.cjs +213 -52
- package/dist/index.js +213 -52
- package/package.json +1 -1
- package/src/collections/TicketMessages.ts +8 -0
- package/src/collections/Tickets.ts +1 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
- package/src/components/TicketConversation/index.tsx +25 -5
- package/src/endpoints/auto-close.ts +31 -7
- package/src/endpoints/index.ts +6 -1
- package/src/endpoints/send-reminder.ts +189 -0
package/dist/index.cjs
CHANGED
|
@@ -1874,11 +1874,20 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1874
1874
|
where: {
|
|
1875
1875
|
and: [
|
|
1876
1876
|
{ status: { equals: "waiting_client" } },
|
|
1877
|
-
{ autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
|
|
1878
1877
|
{
|
|
1879
1878
|
or: [
|
|
1880
|
-
{
|
|
1881
|
-
|
|
1879
|
+
{
|
|
1880
|
+
and: [
|
|
1881
|
+
{ autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
|
|
1882
|
+
{
|
|
1883
|
+
or: [
|
|
1884
|
+
{ lastClientMessageAt: { exists: false } },
|
|
1885
|
+
{ lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
|
|
1886
|
+
]
|
|
1887
|
+
}
|
|
1888
|
+
]
|
|
1889
|
+
},
|
|
1890
|
+
{ autoCloseScheduledAt: { less_than_equal: now.toISOString() } }
|
|
1882
1891
|
]
|
|
1883
1892
|
}
|
|
1884
1893
|
]
|
|
@@ -1894,11 +1903,13 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1894
1903
|
const ticketNumber = t.ticketNumber || "TK-????";
|
|
1895
1904
|
const subject = t.subject || "Support";
|
|
1896
1905
|
const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
|
|
1906
|
+
const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
|
|
1907
|
+
const noteBody = viaSchedule ? "Ticket r\xE9solu automatiquement \u2014 relance manuelle rest\xE9e sans r\xE9ponse du client" : `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`;
|
|
1897
1908
|
await payload.create({
|
|
1898
1909
|
collection: slugs.ticketMessages,
|
|
1899
1910
|
data: {
|
|
1900
1911
|
ticket: t.id,
|
|
1901
|
-
body:
|
|
1912
|
+
body: noteBody,
|
|
1902
1913
|
authorType: "admin",
|
|
1903
1914
|
isInternal: true,
|
|
1904
1915
|
skipNotification: true
|
|
@@ -1908,7 +1919,9 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1908
1919
|
await payload.update({
|
|
1909
1920
|
collection: slugs.tickets,
|
|
1910
1921
|
id: t.id,
|
|
1911
|
-
|
|
1922
|
+
// Clear the armed deadline so a later manual reopen can't be
|
|
1923
|
+
// closed instantly by a stale timestamp.
|
|
1924
|
+
data: { status: "resolved", autoCloseScheduledAt: null },
|
|
1912
1925
|
overrideAccess: true
|
|
1913
1926
|
});
|
|
1914
1927
|
if (client?.email) {
|
|
@@ -1919,7 +1932,7 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1919
1932
|
subject: `[${ticketNumber}] Ticket r\xE9solu \u2014 ${subject}`,
|
|
1920
1933
|
html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
1921
1934
|
<p>Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,</p>
|
|
1922
|
-
<p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em> \u2014 a \xE9t\xE9 r\xE9solu automatiquement apr\xE8s ${closeTotalDays} jours sans r\xE9ponse.</p>
|
|
1935
|
+
<p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em> \u2014 a \xE9t\xE9 r\xE9solu automatiquement ${viaSchedule ? "faute de r\xE9ponse de votre part" : `apr\xE8s ${closeTotalDays} jours sans r\xE9ponse`}.</p>
|
|
1923
1936
|
<p>Si vous avez encore besoin d'aide, n'h\xE9sitez pas \xE0 rouvrir ce ticket ou \xE0 en cr\xE9er un nouveau.</p>
|
|
1924
1937
|
<p><a href="${portalUrl}">Consulter le ticket</a></p>
|
|
1925
1938
|
</div>`
|
|
@@ -1944,6 +1957,190 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1944
1957
|
};
|
|
1945
1958
|
}
|
|
1946
1959
|
|
|
1960
|
+
// src/utils/rateLimiter.ts
|
|
1961
|
+
var RateLimiter = class {
|
|
1962
|
+
constructor(windowMs, maxRequests) {
|
|
1963
|
+
this.windowMs = windowMs;
|
|
1964
|
+
this.maxRequests = maxRequests;
|
|
1965
|
+
const timer = setInterval(() => this.cleanup(), windowMs);
|
|
1966
|
+
timer.unref();
|
|
1967
|
+
}
|
|
1968
|
+
windowMs;
|
|
1969
|
+
maxRequests;
|
|
1970
|
+
store = /* @__PURE__ */ new Map();
|
|
1971
|
+
/**
|
|
1972
|
+
* Check if a key has exceeded the rate limit.
|
|
1973
|
+
* Returns true if the request should be blocked.
|
|
1974
|
+
*/
|
|
1975
|
+
check(key) {
|
|
1976
|
+
const now = Date.now();
|
|
1977
|
+
const entry = this.store.get(key);
|
|
1978
|
+
if (!entry || now > entry.resetAt) {
|
|
1979
|
+
this.store.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
1980
|
+
return false;
|
|
1981
|
+
}
|
|
1982
|
+
entry.count++;
|
|
1983
|
+
return entry.count > this.maxRequests;
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Reset the counter for a specific key.
|
|
1987
|
+
*/
|
|
1988
|
+
reset(key) {
|
|
1989
|
+
this.store.delete(key);
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Remove expired entries from the store.
|
|
1993
|
+
*/
|
|
1994
|
+
cleanup() {
|
|
1995
|
+
const now = Date.now();
|
|
1996
|
+
for (const [key, entry] of this.store) {
|
|
1997
|
+
if (now > entry.resetAt) {
|
|
1998
|
+
this.store.delete(key);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
};
|
|
2003
|
+
|
|
2004
|
+
// src/endpoints/send-reminder.ts
|
|
2005
|
+
var reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30);
|
|
2006
|
+
var MIN_HOURS = 1;
|
|
2007
|
+
var MAX_HOURS = 24 * 30;
|
|
2008
|
+
function formatFr(date, withTime) {
|
|
2009
|
+
return date.toLocaleString("fr-FR", {
|
|
2010
|
+
day: "numeric",
|
|
2011
|
+
month: "long",
|
|
2012
|
+
year: "numeric",
|
|
2013
|
+
...withTime ? { hour: "2-digit", minute: "2-digit" } : {},
|
|
2014
|
+
timeZone: "Europe/Paris"
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
function createSendReminderEndpoint(slugs) {
|
|
2018
|
+
return {
|
|
2019
|
+
path: "/support/send-reminder",
|
|
2020
|
+
method: "post",
|
|
2021
|
+
handler: async (req) => {
|
|
2022
|
+
try {
|
|
2023
|
+
const payload = req.payload;
|
|
2024
|
+
requireAdmin(req, slugs);
|
|
2025
|
+
if (reminderLimiter.check(String(req.user.id))) {
|
|
2026
|
+
return Response.json(
|
|
2027
|
+
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2028
|
+
{ status: 429 }
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
let body;
|
|
2032
|
+
try {
|
|
2033
|
+
body = await req.json();
|
|
2034
|
+
} catch {
|
|
2035
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
2036
|
+
}
|
|
2037
|
+
const { ticketId } = body;
|
|
2038
|
+
if (!ticketId) {
|
|
2039
|
+
return Response.json({ error: "ticketId requis" }, { status: 400 });
|
|
2040
|
+
}
|
|
2041
|
+
const rawHours = Number(body.hours);
|
|
2042
|
+
const hours = Number.isFinite(rawHours) ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours))) : 24;
|
|
2043
|
+
const ticket = await payload.findByID({
|
|
2044
|
+
collection: slugs.tickets,
|
|
2045
|
+
id: ticketId,
|
|
2046
|
+
depth: 1,
|
|
2047
|
+
overrideAccess: true
|
|
2048
|
+
});
|
|
2049
|
+
if (!ticket) {
|
|
2050
|
+
return Response.json({ error: "Ticket introuvable" }, { status: 404 });
|
|
2051
|
+
}
|
|
2052
|
+
const client = typeof ticket.client === "object" ? ticket.client : null;
|
|
2053
|
+
if (!client?.email) {
|
|
2054
|
+
return Response.json({ error: "Client sans email" }, { status: 400 });
|
|
2055
|
+
}
|
|
2056
|
+
const now = /* @__PURE__ */ new Date();
|
|
2057
|
+
const deadline = new Date(now.getTime() + hours * 60 * 60 * 1e3);
|
|
2058
|
+
const settings = await readSupportSettings(payload);
|
|
2059
|
+
const ticketNumber = ticket.ticketNumber || "TK-????";
|
|
2060
|
+
const subject = ticket.subject || "Support";
|
|
2061
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
|
|
2062
|
+
const portalUrl = `${baseUrl}/support/tickets/${ticketId}`;
|
|
2063
|
+
const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
|
|
2064
|
+
let waitingSince = ticket.firstResponseAt || ticket.createdAt;
|
|
2065
|
+
try {
|
|
2066
|
+
const lastAdminMsg = await payload.find({
|
|
2067
|
+
collection: slugs.ticketMessages,
|
|
2068
|
+
where: {
|
|
2069
|
+
and: [
|
|
2070
|
+
{ ticket: { equals: ticketId } },
|
|
2071
|
+
{ authorType: { equals: "admin" } },
|
|
2072
|
+
{ isInternal: { equals: false } }
|
|
2073
|
+
]
|
|
2074
|
+
},
|
|
2075
|
+
sort: "-createdAt",
|
|
2076
|
+
limit: 1,
|
|
2077
|
+
depth: 0,
|
|
2078
|
+
overrideAccess: true
|
|
2079
|
+
});
|
|
2080
|
+
if (lastAdminMsg.docs.length > 0 && lastAdminMsg.docs[0].createdAt) {
|
|
2081
|
+
waitingSince = lastAdminMsg.docs[0].createdAt;
|
|
2082
|
+
}
|
|
2083
|
+
} catch {
|
|
2084
|
+
}
|
|
2085
|
+
const sinceLabel = waitingSince ? formatFr(new Date(waitingSince), false) : null;
|
|
2086
|
+
const deadlineLabel = formatFr(deadline, true);
|
|
2087
|
+
await payload.sendEmail({
|
|
2088
|
+
to: client.email,
|
|
2089
|
+
...replyTo ? { replyTo } : {},
|
|
2090
|
+
subject: `Rappel : [${ticketNumber}] ${subject} \u2014 votre r\xE9ponse est attendue`,
|
|
2091
|
+
html: emailWrapper(`Votre ticket attend votre r\xE9ponse`, [
|
|
2092
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
|
|
2093
|
+
emailParagraph(
|
|
2094
|
+
`Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(String(subject))}</em> \u2014 est en attente de votre r\xE9ponse${sinceLabel ? ` depuis le ${escapeHtml(sinceLabel)}` : ""}.`
|
|
2095
|
+
),
|
|
2096
|
+
emailParagraph(
|
|
2097
|
+
`<strong>Sans retour de votre part, ce ticket sera automatiquement cl\xF4tur\xE9 le ${escapeHtml(deadlineLabel)}.</strong>`
|
|
2098
|
+
),
|
|
2099
|
+
emailParagraph(
|
|
2100
|
+
`Si vous avez encore besoin d'assistance, il vous suffit de r\xE9pondre \xE0 ce message \u2014 votre r\xE9ponse maintiendra le ticket ouvert.`
|
|
2101
|
+
),
|
|
2102
|
+
emailButton("R\xE9pondre au ticket", portalUrl, "primary")
|
|
2103
|
+
].join(""), {
|
|
2104
|
+
kind: "alert",
|
|
2105
|
+
preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
|
|
2106
|
+
})
|
|
2107
|
+
});
|
|
2108
|
+
await payload.create({
|
|
2109
|
+
collection: slugs.ticketMessages,
|
|
2110
|
+
data: {
|
|
2111
|
+
ticket: ticketId,
|
|
2112
|
+
body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
|
|
2113
|
+
authorType: "admin",
|
|
2114
|
+
isInternal: true,
|
|
2115
|
+
skipNotification: true
|
|
2116
|
+
},
|
|
2117
|
+
overrideAccess: true
|
|
2118
|
+
});
|
|
2119
|
+
await payload.update({
|
|
2120
|
+
collection: slugs.tickets,
|
|
2121
|
+
id: ticketId,
|
|
2122
|
+
data: {
|
|
2123
|
+
status: "waiting_client",
|
|
2124
|
+
autoCloseRemindedAt: now.toISOString(),
|
|
2125
|
+
autoCloseScheduledAt: deadline.toISOString()
|
|
2126
|
+
},
|
|
2127
|
+
overrideAccess: true
|
|
2128
|
+
});
|
|
2129
|
+
return Response.json({
|
|
2130
|
+
success: true,
|
|
2131
|
+
sentTo: client.email,
|
|
2132
|
+
scheduledCloseAt: deadline.toISOString()
|
|
2133
|
+
});
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
const authResponse = handleAuthError(error);
|
|
2136
|
+
if (authResponse) return authResponse;
|
|
2137
|
+
console.error("[send-reminder] Error:", error);
|
|
2138
|
+
return Response.json({ error: "Erreur interne" }, { status: 500 });
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
|
|
1947
2144
|
// src/endpoints/statuses.ts
|
|
1948
2145
|
function createStatusesEndpoint(slugs) {
|
|
1949
2146
|
return {
|
|
@@ -2140,50 +2337,6 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2140
2337
|
};
|
|
2141
2338
|
}
|
|
2142
2339
|
|
|
2143
|
-
// src/utils/rateLimiter.ts
|
|
2144
|
-
var RateLimiter = class {
|
|
2145
|
-
constructor(windowMs, maxRequests) {
|
|
2146
|
-
this.windowMs = windowMs;
|
|
2147
|
-
this.maxRequests = maxRequests;
|
|
2148
|
-
const timer = setInterval(() => this.cleanup(), windowMs);
|
|
2149
|
-
timer.unref();
|
|
2150
|
-
}
|
|
2151
|
-
windowMs;
|
|
2152
|
-
maxRequests;
|
|
2153
|
-
store = /* @__PURE__ */ new Map();
|
|
2154
|
-
/**
|
|
2155
|
-
* Check if a key has exceeded the rate limit.
|
|
2156
|
-
* Returns true if the request should be blocked.
|
|
2157
|
-
*/
|
|
2158
|
-
check(key) {
|
|
2159
|
-
const now = Date.now();
|
|
2160
|
-
const entry = this.store.get(key);
|
|
2161
|
-
if (!entry || now > entry.resetAt) {
|
|
2162
|
-
this.store.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
2163
|
-
return false;
|
|
2164
|
-
}
|
|
2165
|
-
entry.count++;
|
|
2166
|
-
return entry.count > this.maxRequests;
|
|
2167
|
-
}
|
|
2168
|
-
/**
|
|
2169
|
-
* Reset the counter for a specific key.
|
|
2170
|
-
*/
|
|
2171
|
-
reset(key) {
|
|
2172
|
-
this.store.delete(key);
|
|
2173
|
-
}
|
|
2174
|
-
/**
|
|
2175
|
-
* Remove expired entries from the store.
|
|
2176
|
-
*/
|
|
2177
|
-
cleanup() {
|
|
2178
|
-
const now = Date.now();
|
|
2179
|
-
for (const [key, entry] of this.store) {
|
|
2180
|
-
if (now > entry.resetAt) {
|
|
2181
|
-
this.store.delete(key);
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2185
|
-
};
|
|
2186
|
-
|
|
2187
2340
|
// src/endpoints/chatbot.ts
|
|
2188
2341
|
var chatbotLimiter = new RateLimiter(6e4, 10);
|
|
2189
2342
|
function createChatbotEndpoint(slugs) {
|
|
@@ -5845,7 +5998,10 @@ function createSupportEndpoints(slugs, options) {
|
|
|
5845
5998
|
endpoints.push(createSignatureGetEndpoint(slugs), createSignaturePostEndpoint(slugs));
|
|
5846
5999
|
}
|
|
5847
6000
|
if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs));
|
|
5848
|
-
if (!f || f.autoClose !== false)
|
|
6001
|
+
if (!f || f.autoClose !== false) {
|
|
6002
|
+
endpoints.push(createAutoCloseEndpoint(slugs));
|
|
6003
|
+
endpoints.push(createSendReminderEndpoint(slugs));
|
|
6004
|
+
}
|
|
5849
6005
|
if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs));
|
|
5850
6006
|
if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs));
|
|
5851
6007
|
if (!f || f.roundRobin !== false) {
|
|
@@ -7023,7 +7179,8 @@ function createTicketsCollection(slugs, options) {
|
|
|
7023
7179
|
]
|
|
7024
7180
|
},
|
|
7025
7181
|
{ name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
|
|
7026
|
-
{ name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
|
|
7182
|
+
{ name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
|
|
7183
|
+
{ name: "autoCloseScheduledAt", type: "date", label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
|
|
7027
7184
|
]
|
|
7028
7185
|
},
|
|
7029
7186
|
// Sidebar
|
|
@@ -7186,8 +7343,12 @@ function createAutoUpdateStatus(slugs) {
|
|
|
7186
7343
|
if (!doc.isInternal) {
|
|
7187
7344
|
if (doc.authorType === "admin") {
|
|
7188
7345
|
updateData.status = "waiting_client";
|
|
7346
|
+
updateData.autoCloseScheduledAt = null;
|
|
7347
|
+
updateData.autoCloseRemindedAt = null;
|
|
7189
7348
|
} else if (doc.authorType === "client" || doc.authorType === "email") {
|
|
7190
7349
|
updateData.lastClientMessageAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7350
|
+
updateData.autoCloseScheduledAt = null;
|
|
7351
|
+
updateData.autoCloseRemindedAt = null;
|
|
7191
7352
|
if (ticket.status && ["waiting_client", "resolved"].includes(ticket.status)) {
|
|
7192
7353
|
updateData.status = "open";
|
|
7193
7354
|
}
|
package/dist/index.js
CHANGED
|
@@ -1868,11 +1868,20 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1868
1868
|
where: {
|
|
1869
1869
|
and: [
|
|
1870
1870
|
{ status: { equals: "waiting_client" } },
|
|
1871
|
-
{ autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
|
|
1872
1871
|
{
|
|
1873
1872
|
or: [
|
|
1874
|
-
{
|
|
1875
|
-
|
|
1873
|
+
{
|
|
1874
|
+
and: [
|
|
1875
|
+
{ autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
|
|
1876
|
+
{
|
|
1877
|
+
or: [
|
|
1878
|
+
{ lastClientMessageAt: { exists: false } },
|
|
1879
|
+
{ lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
|
|
1880
|
+
]
|
|
1881
|
+
}
|
|
1882
|
+
]
|
|
1883
|
+
},
|
|
1884
|
+
{ autoCloseScheduledAt: { less_than_equal: now.toISOString() } }
|
|
1876
1885
|
]
|
|
1877
1886
|
}
|
|
1878
1887
|
]
|
|
@@ -1888,11 +1897,13 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1888
1897
|
const ticketNumber = t.ticketNumber || "TK-????";
|
|
1889
1898
|
const subject = t.subject || "Support";
|
|
1890
1899
|
const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS;
|
|
1900
|
+
const viaSchedule = !!t.autoCloseScheduledAt && new Date(t.autoCloseScheduledAt).getTime() <= now.getTime();
|
|
1901
|
+
const noteBody = viaSchedule ? "Ticket r\xE9solu automatiquement \u2014 relance manuelle rest\xE9e sans r\xE9ponse du client" : `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`;
|
|
1891
1902
|
await payload.create({
|
|
1892
1903
|
collection: slugs.ticketMessages,
|
|
1893
1904
|
data: {
|
|
1894
1905
|
ticket: t.id,
|
|
1895
|
-
body:
|
|
1906
|
+
body: noteBody,
|
|
1896
1907
|
authorType: "admin",
|
|
1897
1908
|
isInternal: true,
|
|
1898
1909
|
skipNotification: true
|
|
@@ -1902,7 +1913,9 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1902
1913
|
await payload.update({
|
|
1903
1914
|
collection: slugs.tickets,
|
|
1904
1915
|
id: t.id,
|
|
1905
|
-
|
|
1916
|
+
// Clear the armed deadline so a later manual reopen can't be
|
|
1917
|
+
// closed instantly by a stale timestamp.
|
|
1918
|
+
data: { status: "resolved", autoCloseScheduledAt: null },
|
|
1906
1919
|
overrideAccess: true
|
|
1907
1920
|
});
|
|
1908
1921
|
if (client?.email) {
|
|
@@ -1913,7 +1926,7 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1913
1926
|
subject: `[${ticketNumber}] Ticket r\xE9solu \u2014 ${subject}`,
|
|
1914
1927
|
html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
1915
1928
|
<p>Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,</p>
|
|
1916
|
-
<p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em> \u2014 a \xE9t\xE9 r\xE9solu automatiquement apr\xE8s ${closeTotalDays} jours sans r\xE9ponse.</p>
|
|
1929
|
+
<p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(subject)}</em> \u2014 a \xE9t\xE9 r\xE9solu automatiquement ${viaSchedule ? "faute de r\xE9ponse de votre part" : `apr\xE8s ${closeTotalDays} jours sans r\xE9ponse`}.</p>
|
|
1917
1930
|
<p>Si vous avez encore besoin d'aide, n'h\xE9sitez pas \xE0 rouvrir ce ticket ou \xE0 en cr\xE9er un nouveau.</p>
|
|
1918
1931
|
<p><a href="${portalUrl}">Consulter le ticket</a></p>
|
|
1919
1932
|
</div>`
|
|
@@ -1938,6 +1951,190 @@ function createAutoCloseEndpoint(slugs) {
|
|
|
1938
1951
|
};
|
|
1939
1952
|
}
|
|
1940
1953
|
|
|
1954
|
+
// src/utils/rateLimiter.ts
|
|
1955
|
+
var RateLimiter = class {
|
|
1956
|
+
constructor(windowMs, maxRequests) {
|
|
1957
|
+
this.windowMs = windowMs;
|
|
1958
|
+
this.maxRequests = maxRequests;
|
|
1959
|
+
const timer = setInterval(() => this.cleanup(), windowMs);
|
|
1960
|
+
timer.unref();
|
|
1961
|
+
}
|
|
1962
|
+
windowMs;
|
|
1963
|
+
maxRequests;
|
|
1964
|
+
store = /* @__PURE__ */ new Map();
|
|
1965
|
+
/**
|
|
1966
|
+
* Check if a key has exceeded the rate limit.
|
|
1967
|
+
* Returns true if the request should be blocked.
|
|
1968
|
+
*/
|
|
1969
|
+
check(key) {
|
|
1970
|
+
const now = Date.now();
|
|
1971
|
+
const entry = this.store.get(key);
|
|
1972
|
+
if (!entry || now > entry.resetAt) {
|
|
1973
|
+
this.store.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
1974
|
+
return false;
|
|
1975
|
+
}
|
|
1976
|
+
entry.count++;
|
|
1977
|
+
return entry.count > this.maxRequests;
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Reset the counter for a specific key.
|
|
1981
|
+
*/
|
|
1982
|
+
reset(key) {
|
|
1983
|
+
this.store.delete(key);
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Remove expired entries from the store.
|
|
1987
|
+
*/
|
|
1988
|
+
cleanup() {
|
|
1989
|
+
const now = Date.now();
|
|
1990
|
+
for (const [key, entry] of this.store) {
|
|
1991
|
+
if (now > entry.resetAt) {
|
|
1992
|
+
this.store.delete(key);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
// src/endpoints/send-reminder.ts
|
|
1999
|
+
var reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30);
|
|
2000
|
+
var MIN_HOURS = 1;
|
|
2001
|
+
var MAX_HOURS = 24 * 30;
|
|
2002
|
+
function formatFr(date, withTime) {
|
|
2003
|
+
return date.toLocaleString("fr-FR", {
|
|
2004
|
+
day: "numeric",
|
|
2005
|
+
month: "long",
|
|
2006
|
+
year: "numeric",
|
|
2007
|
+
...withTime ? { hour: "2-digit", minute: "2-digit" } : {},
|
|
2008
|
+
timeZone: "Europe/Paris"
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
function createSendReminderEndpoint(slugs) {
|
|
2012
|
+
return {
|
|
2013
|
+
path: "/support/send-reminder",
|
|
2014
|
+
method: "post",
|
|
2015
|
+
handler: async (req) => {
|
|
2016
|
+
try {
|
|
2017
|
+
const payload = req.payload;
|
|
2018
|
+
requireAdmin(req, slugs);
|
|
2019
|
+
if (reminderLimiter.check(String(req.user.id))) {
|
|
2020
|
+
return Response.json(
|
|
2021
|
+
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2022
|
+
{ status: 429 }
|
|
2023
|
+
);
|
|
2024
|
+
}
|
|
2025
|
+
let body;
|
|
2026
|
+
try {
|
|
2027
|
+
body = await req.json();
|
|
2028
|
+
} catch {
|
|
2029
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
2030
|
+
}
|
|
2031
|
+
const { ticketId } = body;
|
|
2032
|
+
if (!ticketId) {
|
|
2033
|
+
return Response.json({ error: "ticketId requis" }, { status: 400 });
|
|
2034
|
+
}
|
|
2035
|
+
const rawHours = Number(body.hours);
|
|
2036
|
+
const hours = Number.isFinite(rawHours) ? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours))) : 24;
|
|
2037
|
+
const ticket = await payload.findByID({
|
|
2038
|
+
collection: slugs.tickets,
|
|
2039
|
+
id: ticketId,
|
|
2040
|
+
depth: 1,
|
|
2041
|
+
overrideAccess: true
|
|
2042
|
+
});
|
|
2043
|
+
if (!ticket) {
|
|
2044
|
+
return Response.json({ error: "Ticket introuvable" }, { status: 404 });
|
|
2045
|
+
}
|
|
2046
|
+
const client = typeof ticket.client === "object" ? ticket.client : null;
|
|
2047
|
+
if (!client?.email) {
|
|
2048
|
+
return Response.json({ error: "Client sans email" }, { status: 400 });
|
|
2049
|
+
}
|
|
2050
|
+
const now = /* @__PURE__ */ new Date();
|
|
2051
|
+
const deadline = new Date(now.getTime() + hours * 60 * 60 * 1e3);
|
|
2052
|
+
const settings = await readSupportSettings(payload);
|
|
2053
|
+
const ticketNumber = ticket.ticketNumber || "TK-????";
|
|
2054
|
+
const subject = ticket.subject || "Support";
|
|
2055
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || "";
|
|
2056
|
+
const portalUrl = `${baseUrl}/support/tickets/${ticketId}`;
|
|
2057
|
+
const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || "";
|
|
2058
|
+
let waitingSince = ticket.firstResponseAt || ticket.createdAt;
|
|
2059
|
+
try {
|
|
2060
|
+
const lastAdminMsg = await payload.find({
|
|
2061
|
+
collection: slugs.ticketMessages,
|
|
2062
|
+
where: {
|
|
2063
|
+
and: [
|
|
2064
|
+
{ ticket: { equals: ticketId } },
|
|
2065
|
+
{ authorType: { equals: "admin" } },
|
|
2066
|
+
{ isInternal: { equals: false } }
|
|
2067
|
+
]
|
|
2068
|
+
},
|
|
2069
|
+
sort: "-createdAt",
|
|
2070
|
+
limit: 1,
|
|
2071
|
+
depth: 0,
|
|
2072
|
+
overrideAccess: true
|
|
2073
|
+
});
|
|
2074
|
+
if (lastAdminMsg.docs.length > 0 && lastAdminMsg.docs[0].createdAt) {
|
|
2075
|
+
waitingSince = lastAdminMsg.docs[0].createdAt;
|
|
2076
|
+
}
|
|
2077
|
+
} catch {
|
|
2078
|
+
}
|
|
2079
|
+
const sinceLabel = waitingSince ? formatFr(new Date(waitingSince), false) : null;
|
|
2080
|
+
const deadlineLabel = formatFr(deadline, true);
|
|
2081
|
+
await payload.sendEmail({
|
|
2082
|
+
to: client.email,
|
|
2083
|
+
...replyTo ? { replyTo } : {},
|
|
2084
|
+
subject: `Rappel : [${ticketNumber}] ${subject} \u2014 votre r\xE9ponse est attendue`,
|
|
2085
|
+
html: emailWrapper(`Votre ticket attend votre r\xE9ponse`, [
|
|
2086
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || "")}</strong>,`),
|
|
2087
|
+
emailParagraph(
|
|
2088
|
+
`Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> \u2014 <em>${escapeHtml(String(subject))}</em> \u2014 est en attente de votre r\xE9ponse${sinceLabel ? ` depuis le ${escapeHtml(sinceLabel)}` : ""}.`
|
|
2089
|
+
),
|
|
2090
|
+
emailParagraph(
|
|
2091
|
+
`<strong>Sans retour de votre part, ce ticket sera automatiquement cl\xF4tur\xE9 le ${escapeHtml(deadlineLabel)}.</strong>`
|
|
2092
|
+
),
|
|
2093
|
+
emailParagraph(
|
|
2094
|
+
`Si vous avez encore besoin d'assistance, il vous suffit de r\xE9pondre \xE0 ce message \u2014 votre r\xE9ponse maintiendra le ticket ouvert.`
|
|
2095
|
+
),
|
|
2096
|
+
emailButton("R\xE9pondre au ticket", portalUrl, "primary")
|
|
2097
|
+
].join(""), {
|
|
2098
|
+
kind: "alert",
|
|
2099
|
+
preheader: `Sans r\xE9ponse de votre part, votre ticket ${ticketNumber} sera cl\xF4tur\xE9 le ${deadlineLabel}.`
|
|
2100
|
+
})
|
|
2101
|
+
});
|
|
2102
|
+
await payload.create({
|
|
2103
|
+
collection: slugs.ticketMessages,
|
|
2104
|
+
data: {
|
|
2105
|
+
ticket: ticketId,
|
|
2106
|
+
body: `Relance envoy\xE9e \xE0 ${client.email}. Fermeture automatique programm\xE9e le ${deadlineLabel} sans r\xE9ponse du client.`,
|
|
2107
|
+
authorType: "admin",
|
|
2108
|
+
isInternal: true,
|
|
2109
|
+
skipNotification: true
|
|
2110
|
+
},
|
|
2111
|
+
overrideAccess: true
|
|
2112
|
+
});
|
|
2113
|
+
await payload.update({
|
|
2114
|
+
collection: slugs.tickets,
|
|
2115
|
+
id: ticketId,
|
|
2116
|
+
data: {
|
|
2117
|
+
status: "waiting_client",
|
|
2118
|
+
autoCloseRemindedAt: now.toISOString(),
|
|
2119
|
+
autoCloseScheduledAt: deadline.toISOString()
|
|
2120
|
+
},
|
|
2121
|
+
overrideAccess: true
|
|
2122
|
+
});
|
|
2123
|
+
return Response.json({
|
|
2124
|
+
success: true,
|
|
2125
|
+
sentTo: client.email,
|
|
2126
|
+
scheduledCloseAt: deadline.toISOString()
|
|
2127
|
+
});
|
|
2128
|
+
} catch (error) {
|
|
2129
|
+
const authResponse = handleAuthError(error);
|
|
2130
|
+
if (authResponse) return authResponse;
|
|
2131
|
+
console.error("[send-reminder] Error:", error);
|
|
2132
|
+
return Response.json({ error: "Erreur interne" }, { status: 500 });
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
|
|
1941
2138
|
// src/endpoints/statuses.ts
|
|
1942
2139
|
function createStatusesEndpoint(slugs) {
|
|
1943
2140
|
return {
|
|
@@ -2134,50 +2331,6 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2134
2331
|
};
|
|
2135
2332
|
}
|
|
2136
2333
|
|
|
2137
|
-
// src/utils/rateLimiter.ts
|
|
2138
|
-
var RateLimiter = class {
|
|
2139
|
-
constructor(windowMs, maxRequests) {
|
|
2140
|
-
this.windowMs = windowMs;
|
|
2141
|
-
this.maxRequests = maxRequests;
|
|
2142
|
-
const timer = setInterval(() => this.cleanup(), windowMs);
|
|
2143
|
-
timer.unref();
|
|
2144
|
-
}
|
|
2145
|
-
windowMs;
|
|
2146
|
-
maxRequests;
|
|
2147
|
-
store = /* @__PURE__ */ new Map();
|
|
2148
|
-
/**
|
|
2149
|
-
* Check if a key has exceeded the rate limit.
|
|
2150
|
-
* Returns true if the request should be blocked.
|
|
2151
|
-
*/
|
|
2152
|
-
check(key) {
|
|
2153
|
-
const now = Date.now();
|
|
2154
|
-
const entry = this.store.get(key);
|
|
2155
|
-
if (!entry || now > entry.resetAt) {
|
|
2156
|
-
this.store.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
2157
|
-
return false;
|
|
2158
|
-
}
|
|
2159
|
-
entry.count++;
|
|
2160
|
-
return entry.count > this.maxRequests;
|
|
2161
|
-
}
|
|
2162
|
-
/**
|
|
2163
|
-
* Reset the counter for a specific key.
|
|
2164
|
-
*/
|
|
2165
|
-
reset(key) {
|
|
2166
|
-
this.store.delete(key);
|
|
2167
|
-
}
|
|
2168
|
-
/**
|
|
2169
|
-
* Remove expired entries from the store.
|
|
2170
|
-
*/
|
|
2171
|
-
cleanup() {
|
|
2172
|
-
const now = Date.now();
|
|
2173
|
-
for (const [key, entry] of this.store) {
|
|
2174
|
-
if (now > entry.resetAt) {
|
|
2175
|
-
this.store.delete(key);
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
};
|
|
2180
|
-
|
|
2181
2334
|
// src/endpoints/chatbot.ts
|
|
2182
2335
|
var chatbotLimiter = new RateLimiter(6e4, 10);
|
|
2183
2336
|
function createChatbotEndpoint(slugs) {
|
|
@@ -5839,7 +5992,10 @@ function createSupportEndpoints(slugs, options) {
|
|
|
5839
5992
|
endpoints.push(createSignatureGetEndpoint(slugs), createSignaturePostEndpoint(slugs));
|
|
5840
5993
|
}
|
|
5841
5994
|
if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs));
|
|
5842
|
-
if (!f || f.autoClose !== false)
|
|
5995
|
+
if (!f || f.autoClose !== false) {
|
|
5996
|
+
endpoints.push(createAutoCloseEndpoint(slugs));
|
|
5997
|
+
endpoints.push(createSendReminderEndpoint(slugs));
|
|
5998
|
+
}
|
|
5843
5999
|
if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs));
|
|
5844
6000
|
if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs));
|
|
5845
6001
|
if (!f || f.roundRobin !== false) {
|
|
@@ -7017,7 +7173,8 @@ function createTicketsCollection(slugs, options) {
|
|
|
7017
7173
|
]
|
|
7018
7174
|
},
|
|
7019
7175
|
{ name: "mergedInto", type: "relationship", relationTo: slugs.tickets, label: "Fusionne dans", admin: { readOnly: true } },
|
|
7020
|
-
{ name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } }
|
|
7176
|
+
{ name: "autoCloseRemindedAt", type: "date", label: "Rappel auto-close envoye", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" } } },
|
|
7177
|
+
{ name: "autoCloseScheduledAt", type: "date", label: "Fermeture auto programmee", admin: { readOnly: true, date: { displayFormat: "dd/MM/yyyy HH:mm" }, description: "Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client." } }
|
|
7021
7178
|
]
|
|
7022
7179
|
},
|
|
7023
7180
|
// Sidebar
|
|
@@ -7180,8 +7337,12 @@ function createAutoUpdateStatus(slugs) {
|
|
|
7180
7337
|
if (!doc.isInternal) {
|
|
7181
7338
|
if (doc.authorType === "admin") {
|
|
7182
7339
|
updateData.status = "waiting_client";
|
|
7340
|
+
updateData.autoCloseScheduledAt = null;
|
|
7341
|
+
updateData.autoCloseRemindedAt = null;
|
|
7183
7342
|
} else if (doc.authorType === "client" || doc.authorType === "email") {
|
|
7184
7343
|
updateData.lastClientMessageAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7344
|
+
updateData.autoCloseScheduledAt = null;
|
|
7345
|
+
updateData.autoCloseRemindedAt = null;
|
|
7185
7346
|
if (ticket.status && ["waiting_client", "resolved"].includes(ticket.status)) {
|
|
7186
7347
|
updateData.status = "open";
|
|
7187
7348
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consilioweb/payload-support",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|