@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/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
- { lastClientMessageAt: { exists: false } },
1881
- { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
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: `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`,
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
- data: { status: "resolved" },
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) endpoints.push(createAutoCloseEndpoint(slugs));
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
- { lastClientMessageAt: { exists: false } },
1875
- { lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } }
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: `Ticket r\xE9solu automatiquement \u2014 sans r\xE9ponse client depuis ${closeTotalDays} jours`,
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
- data: { status: "resolved" },
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) endpoints.push(createAutoCloseEndpoint(slugs));
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.15.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",