@consilioweb/payload-support 1.0.0 → 1.1.1
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/README.md +14 -4
- package/dist/index.cjs +158 -5
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +157 -5
- package/package.json +8 -3
- package/src/__tests__/integration/per-team-sla.test.ts +44 -0
- package/src/__tests__/integration/push.test.ts +75 -0
- package/src/collections/PushSubscription.ts +28 -0
- package/src/collections/SlaPolicies.ts +10 -0
- package/src/collections/TicketMessages.ts +11 -0
- package/src/collections/Tickets.ts +4 -1
- package/src/collections/index.ts +1 -0
- package/src/endpoints/admin-stats.ts +7 -3
- package/src/endpoints/index.ts +4 -0
- package/src/endpoints/push.ts +49 -0
- package/src/hooks/checkSLA.ts +15 -1
- package/src/plugin.ts +2 -0
- package/src/utils/push.ts +63 -0
- package/src/utils/slugs.ts +2 -0
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
<div align="center">
|
|
7
7
|
|
|
8
|
+
[](https://www.npmjs.com/package/@consilioweb/payload-support)
|
|
8
9
|
[](LICENSE)
|
|
9
|
-
[](package.json)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](https://payloadcms.com)
|
|
12
12
|
[](src/__tests__)
|
|
@@ -228,6 +228,9 @@ import type { SupportPluginConfig, SupportFeatures } from '@consilioweb/payload-
|
|
|
228
228
|
| `CRON_SECRET` | for crons | `x-cron-secret` header for the cron endpoints. |
|
|
229
229
|
| `ANTHROPIC_API_KEY` / `OLLAMA_API_URL` | if AI | AI provider keys/URL. |
|
|
230
230
|
| `GOOGLE_OAUTH_CLIENT_ID` / `_SECRET` | if OAuth | Portal Google sign-in. |
|
|
231
|
+
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | if push | Web Push keys (`npx web-push generate-vapid-keys`). |
|
|
232
|
+
| `VAPID_SUBJECT` | optional | Push contact (`mailto:` or URL), defaults to `mailto:support@example.com`. |
|
|
233
|
+
| `SUPPORT_TEAM_SCOPING` | optional | `1` to scope agents to their team's tickets. |
|
|
231
234
|
| `SUPPORT_EMAIL` / `SUPPORT_REPLY_TO` | optional | From/reply-to addresses. |
|
|
232
235
|
|
|
233
236
|
<img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" width="100%" alt="" />
|
|
@@ -368,6 +371,13 @@ npm run typecheck && npm test && npm run build
|
|
|
368
371
|
|
|
369
372
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
370
373
|
|
|
374
|
+
### [1.1.0] — 2026-06-25
|
|
375
|
+
|
|
376
|
+
- 🎯 **Per-team SLA policies & dashboards** (team policy overrides the default, `?teamId=` scoping).
|
|
377
|
+
- 🔔 **Native push / browser notifications** (Web Push / VAPID, agent pushed on new client messages).
|
|
378
|
+
- 🎭 **End-to-end UI test harness** (browser-driven admin via Playwright, `pnpm test:e2e`).
|
|
379
|
+
- ✅ 109 integration tests (up from 102).
|
|
380
|
+
|
|
371
381
|
### [1.0.0] — 2026-06-25
|
|
372
382
|
|
|
373
383
|
- ✨ Complete ticketing, SLA (+ pause-on-hold), automation & **visual rules engine**.
|
|
@@ -405,9 +415,9 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
|
405
415
|
- [x] Full admin i18n (all views)
|
|
406
416
|
- [x] Social channels (WhatsApp, Messenger)
|
|
407
417
|
- [x] Multi-team / workspaces mode
|
|
408
|
-
- [
|
|
409
|
-
- [
|
|
410
|
-
- [
|
|
418
|
+
- [x] Per-team SLA policies & dashboards
|
|
419
|
+
- [x] Native push / browser notifications
|
|
420
|
+
- [x] End-to-end UI test harness (browser-driven admin)
|
|
411
421
|
|
|
412
422
|
<img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" width="100%" alt="" />
|
|
413
423
|
|
package/dist/index.cjs
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
var crypto3 = require('crypto');
|
|
4
4
|
var PDFDocument = require('pdfkit');
|
|
5
5
|
var payload = require('payload');
|
|
6
|
+
var webpush = require('web-push');
|
|
6
7
|
var sanitizeHtml = require('sanitize-html');
|
|
7
8
|
|
|
8
9
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
9
10
|
|
|
10
11
|
var crypto3__default = /*#__PURE__*/_interopDefault(crypto3);
|
|
11
12
|
var PDFDocument__default = /*#__PURE__*/_interopDefault(PDFDocument);
|
|
13
|
+
var webpush__default = /*#__PURE__*/_interopDefault(webpush);
|
|
12
14
|
var sanitizeHtml__default = /*#__PURE__*/_interopDefault(sanitizeHtml);
|
|
13
15
|
|
|
14
16
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
@@ -72,6 +74,7 @@ var DEFAULT_SLUGS = {
|
|
|
72
74
|
notificationQueue: "notification-queue",
|
|
73
75
|
automationRules: "automation-rules",
|
|
74
76
|
supportTeams: "support-teams",
|
|
77
|
+
pushSubscriptions: "push-subscriptions",
|
|
75
78
|
users: "users",
|
|
76
79
|
media: "media"
|
|
77
80
|
};
|
|
@@ -3275,11 +3278,13 @@ function createAdminStatsEndpoint(slugs) {
|
|
|
3275
3278
|
try {
|
|
3276
3279
|
const payload = req.payload;
|
|
3277
3280
|
requireAdmin(req, slugs);
|
|
3281
|
+
const teamId = new URL(req.url).searchParams.get("teamId");
|
|
3282
|
+
const teamAnd = teamId ? [{ team: { equals: Number(teamId) } }] : [];
|
|
3278
3283
|
const statuses = ["open", "waiting_client", "resolved"];
|
|
3279
3284
|
const statusCounts = await Promise.all(
|
|
3280
3285
|
statuses.map(async (status) => {
|
|
3281
3286
|
const result = await dbCount(payload, slugs.tickets, {
|
|
3282
|
-
where: { status: { equals: status } },
|
|
3287
|
+
where: { and: [{ status: { equals: status } }, ...teamAnd] },
|
|
3283
3288
|
overrideAccess: true
|
|
3284
3289
|
});
|
|
3285
3290
|
return [status, result.totalDocs];
|
|
@@ -3291,7 +3296,7 @@ function createAdminStatsEndpoint(slugs) {
|
|
|
3291
3296
|
const priorityCounts = await Promise.all(
|
|
3292
3297
|
priorities.map(async (priority) => {
|
|
3293
3298
|
const result = await dbCount(payload, slugs.tickets, {
|
|
3294
|
-
where: { priority: { equals: priority } },
|
|
3299
|
+
where: { and: [{ priority: { equals: priority } }, ...teamAnd] },
|
|
3295
3300
|
overrideAccess: true
|
|
3296
3301
|
});
|
|
3297
3302
|
return [priority, result.totalDocs];
|
|
@@ -3302,7 +3307,7 @@ function createAdminStatsEndpoint(slugs) {
|
|
|
3302
3307
|
const categoryCounts = await Promise.all(
|
|
3303
3308
|
categories.map(async (category) => {
|
|
3304
3309
|
const result = await dbCount(payload, slugs.tickets, {
|
|
3305
|
-
where: { category: { equals: category } },
|
|
3310
|
+
where: { and: [{ category: { equals: category } }, ...teamAnd] },
|
|
3306
3311
|
overrideAccess: true
|
|
3307
3312
|
});
|
|
3308
3313
|
return [category, result.totalDocs];
|
|
@@ -5881,6 +5886,94 @@ function createChannelsWebhookEndpoint(slugs) {
|
|
|
5881
5886
|
}
|
|
5882
5887
|
};
|
|
5883
5888
|
}
|
|
5889
|
+
var configured = false;
|
|
5890
|
+
function ensureVapid() {
|
|
5891
|
+
if (configured) return true;
|
|
5892
|
+
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
|
5893
|
+
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
|
5894
|
+
if (!publicKey || !privateKey) return false;
|
|
5895
|
+
webpush__default.default.setVapidDetails(process.env.VAPID_SUBJECT || "mailto:support@example.com", publicKey, privateKey);
|
|
5896
|
+
configured = true;
|
|
5897
|
+
return true;
|
|
5898
|
+
}
|
|
5899
|
+
function getVapidPublicKey() {
|
|
5900
|
+
return process.env.VAPID_PUBLIC_KEY || null;
|
|
5901
|
+
}
|
|
5902
|
+
async function sendPushToUser(payload, slugs, userId, notification) {
|
|
5903
|
+
if (!ensureVapid()) return { sent: 0 };
|
|
5904
|
+
let sent = 0;
|
|
5905
|
+
try {
|
|
5906
|
+
const subs = await dbFind(payload, slugs.pushSubscriptions, { where: { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true });
|
|
5907
|
+
for (const s of subs.docs) {
|
|
5908
|
+
const row = s;
|
|
5909
|
+
if (!row.endpoint || !row.p256dh || !row.auth) continue;
|
|
5910
|
+
try {
|
|
5911
|
+
await webpush__default.default.sendNotification(
|
|
5912
|
+
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|
|
5913
|
+
JSON.stringify(notification)
|
|
5914
|
+
);
|
|
5915
|
+
sent++;
|
|
5916
|
+
} catch (err) {
|
|
5917
|
+
const code = err?.statusCode;
|
|
5918
|
+
if (code === 404 || code === 410) {
|
|
5919
|
+
try {
|
|
5920
|
+
await dbDelete(payload, slugs.pushSubscriptions, { id: row.id, overrideAccess: true });
|
|
5921
|
+
} catch {
|
|
5922
|
+
}
|
|
5923
|
+
}
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
} catch (err) {
|
|
5927
|
+
console.error("[support] Push send failed:", err);
|
|
5928
|
+
}
|
|
5929
|
+
return { sent };
|
|
5930
|
+
}
|
|
5931
|
+
|
|
5932
|
+
// src/endpoints/push.ts
|
|
5933
|
+
function createVapidKeyEndpoint() {
|
|
5934
|
+
return {
|
|
5935
|
+
path: "/support/push/vapid-public-key",
|
|
5936
|
+
method: "get",
|
|
5937
|
+
handler: async () => Response.json({ publicKey: getVapidPublicKey() })
|
|
5938
|
+
};
|
|
5939
|
+
}
|
|
5940
|
+
function createPushSubscribeEndpoint(slugs) {
|
|
5941
|
+
return {
|
|
5942
|
+
path: "/support/push/subscribe",
|
|
5943
|
+
method: "post",
|
|
5944
|
+
handler: async (req) => {
|
|
5945
|
+
try {
|
|
5946
|
+
requireAdmin(req, slugs);
|
|
5947
|
+
let body;
|
|
5948
|
+
try {
|
|
5949
|
+
body = await req.json();
|
|
5950
|
+
} catch {
|
|
5951
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5952
|
+
}
|
|
5953
|
+
const sub = body?.subscription;
|
|
5954
|
+
const endpoint = sub?.endpoint;
|
|
5955
|
+
const p256dh = sub?.keys?.p256dh;
|
|
5956
|
+
const auth = sub?.keys?.auth;
|
|
5957
|
+
if (!endpoint || !p256dh || !auth) {
|
|
5958
|
+
return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
|
|
5959
|
+
}
|
|
5960
|
+
const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
|
|
5961
|
+
const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
|
|
5962
|
+
if (existing.docs.length > 0) {
|
|
5963
|
+
await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
|
|
5964
|
+
} else {
|
|
5965
|
+
await dbCreate(req.payload, slugs.pushSubscriptions, { data, overrideAccess: true });
|
|
5966
|
+
}
|
|
5967
|
+
return Response.json({ ok: true });
|
|
5968
|
+
} catch (error) {
|
|
5969
|
+
const authResponse = handleAuthError(error);
|
|
5970
|
+
if (authResponse) return authResponse;
|
|
5971
|
+
console.error("[push] Error:", error);
|
|
5972
|
+
return Response.json({ error: "Erreur interne" }, { status: 500 });
|
|
5973
|
+
}
|
|
5974
|
+
}
|
|
5975
|
+
};
|
|
5976
|
+
}
|
|
5884
5977
|
|
|
5885
5978
|
// src/endpoints/user-prefs.ts
|
|
5886
5979
|
var PREF_KEY_PREFIX = "support-user-prefs";
|
|
@@ -6508,6 +6601,8 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6508
6601
|
if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
|
|
6509
6602
|
endpoints.push(createProcessDigestsEndpoint(slugs));
|
|
6510
6603
|
endpoints.push(createChannelsWebhookEndpoint(slugs));
|
|
6604
|
+
endpoints.push(createVapidKeyEndpoint());
|
|
6605
|
+
endpoints.push(createPushSubscribeEndpoint(slugs));
|
|
6511
6606
|
return endpoints;
|
|
6512
6607
|
}
|
|
6513
6608
|
|
|
@@ -6829,6 +6924,20 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
|
|
|
6829
6924
|
}
|
|
6830
6925
|
}
|
|
6831
6926
|
const priority = field(ticket, "priority") || "normal";
|
|
6927
|
+
const teamRef = field(ticket, "team");
|
|
6928
|
+
const teamId = typeof teamRef === "object" && teamRef !== null ? teamRef.id : teamRef;
|
|
6929
|
+
if (teamId) {
|
|
6930
|
+
try {
|
|
6931
|
+
const teamPolicies = await dbFind(payload, slugs.slaPolicies, {
|
|
6932
|
+
where: { and: [{ team: { equals: teamId } }, { priority: { equals: priority } }] },
|
|
6933
|
+
limit: 1,
|
|
6934
|
+
depth: 0,
|
|
6935
|
+
overrideAccess: true
|
|
6936
|
+
});
|
|
6937
|
+
if (teamPolicies.docs.length > 0) return teamPolicies.docs[0];
|
|
6938
|
+
} catch {
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6832
6941
|
try {
|
|
6833
6942
|
const defaults = await dbFind(payload, slugs.slaPolicies, {
|
|
6834
6943
|
where: {
|
|
@@ -7185,7 +7294,7 @@ function createRestrictClientUpdates(slugs) {
|
|
|
7185
7294
|
return async ({ data, operation, req, originalDoc }) => {
|
|
7186
7295
|
if (operation !== "update") return data;
|
|
7187
7296
|
if (req.user?.collection !== slugs.supportClients) return data;
|
|
7188
|
-
const allowedStatuses = ["open", "resolved"];
|
|
7297
|
+
const allowedStatuses = ["open", "waiting_support", "resolved"];
|
|
7189
7298
|
const newData = {};
|
|
7190
7299
|
if (data.status && allowedStatuses.includes(data.status)) {
|
|
7191
7300
|
newData.status = data.status;
|
|
@@ -8211,6 +8320,15 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
|
|
|
8211
8320
|
}
|
|
8212
8321
|
const preview = doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body;
|
|
8213
8322
|
const headerTitle = isNewTicket ? `Nouveau ticket ${ticketNumber}` : `Nouveau message \u2014 ${ticketNumber}`;
|
|
8323
|
+
const assignedId = assignedAdmin?.id ?? (ticket.assignedTo && typeof ticket.assignedTo !== "object" ? ticket.assignedTo : void 0);
|
|
8324
|
+
if (assignedId) {
|
|
8325
|
+
void sendPushToUser(payload, slugs, assignedId, {
|
|
8326
|
+
title: headerTitle,
|
|
8327
|
+
body: `${clientName}: ${preview || subject}`,
|
|
8328
|
+
url: `/admin/collections/${slugs.tickets}/${ticketId}`
|
|
8329
|
+
}).catch(() => {
|
|
8330
|
+
});
|
|
8331
|
+
}
|
|
8214
8332
|
if (primaryEmail) {
|
|
8215
8333
|
await payload.sendEmail({
|
|
8216
8334
|
// INTERNAL email (to the support team) — recipients are the contact
|
|
@@ -9814,6 +9932,16 @@ function createSlaPoliciesCollection(slugs) {
|
|
|
9814
9932
|
admin: {
|
|
9815
9933
|
description: "Utiliser cette politique pour les tickets sans SLA sp\xE9cifique (par priorit\xE9)"
|
|
9816
9934
|
}
|
|
9935
|
+
},
|
|
9936
|
+
{
|
|
9937
|
+
name: "team",
|
|
9938
|
+
type: "relationship",
|
|
9939
|
+
relationTo: slugs.supportTeams,
|
|
9940
|
+
index: true,
|
|
9941
|
+
label: "\xC9quipe (optionnel)",
|
|
9942
|
+
admin: {
|
|
9943
|
+
description: "Limiter cette politique \xE0 une \xE9quipe \u2014 prioritaire sur la politique par d\xE9faut"
|
|
9944
|
+
}
|
|
9817
9945
|
}
|
|
9818
9946
|
],
|
|
9819
9947
|
access: {
|
|
@@ -10456,6 +10584,30 @@ function createSupportTeamCollection(slugs) {
|
|
|
10456
10584
|
};
|
|
10457
10585
|
}
|
|
10458
10586
|
|
|
10587
|
+
// src/collections/PushSubscription.ts
|
|
10588
|
+
function createPushSubscriptionCollection(slugs) {
|
|
10589
|
+
return {
|
|
10590
|
+
slug: slugs.pushSubscriptions,
|
|
10591
|
+
labels: { singular: "Abonnement push", plural: "Abonnements push" },
|
|
10592
|
+
admin: { hidden: true, group: "Support", defaultColumns: ["user", "endpoint", "createdAt"] },
|
|
10593
|
+
access: {
|
|
10594
|
+
create: () => false,
|
|
10595
|
+
// written via the subscribe endpoint (overrideAccess)
|
|
10596
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
10597
|
+
update: () => false,
|
|
10598
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10599
|
+
},
|
|
10600
|
+
fields: [
|
|
10601
|
+
{ name: "user", type: "relationship", relationTo: slugs.users, index: true, label: "Agent" },
|
|
10602
|
+
{ name: "endpoint", type: "text", required: true, index: true, label: "Endpoint" },
|
|
10603
|
+
{ name: "p256dh", type: "text", label: "Cl\xE9 p256dh" },
|
|
10604
|
+
{ name: "auth", type: "text", label: "Cl\xE9 auth" },
|
|
10605
|
+
{ name: "userAgent", type: "text", label: "User-Agent" }
|
|
10606
|
+
],
|
|
10607
|
+
timestamps: true
|
|
10608
|
+
};
|
|
10609
|
+
}
|
|
10610
|
+
|
|
10459
10611
|
// src/plugin.ts
|
|
10460
10612
|
function viewConfig(component, path) {
|
|
10461
10613
|
return { Component: component, path };
|
|
@@ -10494,7 +10646,8 @@ function supportPlugin(config) {
|
|
|
10494
10646
|
createTicketCollaboratorsCollection(slugs),
|
|
10495
10647
|
createNotificationQueueCollection(slugs),
|
|
10496
10648
|
createAutomationRulesCollection(slugs),
|
|
10497
|
-
createSupportTeamCollection(slugs)
|
|
10649
|
+
createSupportTeamCollection(slugs),
|
|
10650
|
+
createPushSubscriptionCollection(slugs)
|
|
10498
10651
|
];
|
|
10499
10652
|
if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
|
|
10500
10653
|
if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto3, { createHmac, randomBytes } from 'crypto';
|
|
2
2
|
import PDFDocument from 'pdfkit';
|
|
3
3
|
import { APIError, getFieldsToSign, jwtSign } from 'payload';
|
|
4
|
+
import webpush from 'web-push';
|
|
4
5
|
import sanitizeHtml from 'sanitize-html';
|
|
5
6
|
|
|
6
7
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
@@ -64,6 +65,7 @@ var DEFAULT_SLUGS = {
|
|
|
64
65
|
notificationQueue: "notification-queue",
|
|
65
66
|
automationRules: "automation-rules",
|
|
66
67
|
supportTeams: "support-teams",
|
|
68
|
+
pushSubscriptions: "push-subscriptions",
|
|
67
69
|
users: "users",
|
|
68
70
|
media: "media"
|
|
69
71
|
};
|
|
@@ -3267,11 +3269,13 @@ function createAdminStatsEndpoint(slugs) {
|
|
|
3267
3269
|
try {
|
|
3268
3270
|
const payload = req.payload;
|
|
3269
3271
|
requireAdmin(req, slugs);
|
|
3272
|
+
const teamId = new URL(req.url).searchParams.get("teamId");
|
|
3273
|
+
const teamAnd = teamId ? [{ team: { equals: Number(teamId) } }] : [];
|
|
3270
3274
|
const statuses = ["open", "waiting_client", "resolved"];
|
|
3271
3275
|
const statusCounts = await Promise.all(
|
|
3272
3276
|
statuses.map(async (status) => {
|
|
3273
3277
|
const result = await dbCount(payload, slugs.tickets, {
|
|
3274
|
-
where: { status: { equals: status } },
|
|
3278
|
+
where: { and: [{ status: { equals: status } }, ...teamAnd] },
|
|
3275
3279
|
overrideAccess: true
|
|
3276
3280
|
});
|
|
3277
3281
|
return [status, result.totalDocs];
|
|
@@ -3283,7 +3287,7 @@ function createAdminStatsEndpoint(slugs) {
|
|
|
3283
3287
|
const priorityCounts = await Promise.all(
|
|
3284
3288
|
priorities.map(async (priority) => {
|
|
3285
3289
|
const result = await dbCount(payload, slugs.tickets, {
|
|
3286
|
-
where: { priority: { equals: priority } },
|
|
3290
|
+
where: { and: [{ priority: { equals: priority } }, ...teamAnd] },
|
|
3287
3291
|
overrideAccess: true
|
|
3288
3292
|
});
|
|
3289
3293
|
return [priority, result.totalDocs];
|
|
@@ -3294,7 +3298,7 @@ function createAdminStatsEndpoint(slugs) {
|
|
|
3294
3298
|
const categoryCounts = await Promise.all(
|
|
3295
3299
|
categories.map(async (category) => {
|
|
3296
3300
|
const result = await dbCount(payload, slugs.tickets, {
|
|
3297
|
-
where: { category: { equals: category } },
|
|
3301
|
+
where: { and: [{ category: { equals: category } }, ...teamAnd] },
|
|
3298
3302
|
overrideAccess: true
|
|
3299
3303
|
});
|
|
3300
3304
|
return [category, result.totalDocs];
|
|
@@ -5873,6 +5877,94 @@ function createChannelsWebhookEndpoint(slugs) {
|
|
|
5873
5877
|
}
|
|
5874
5878
|
};
|
|
5875
5879
|
}
|
|
5880
|
+
var configured = false;
|
|
5881
|
+
function ensureVapid() {
|
|
5882
|
+
if (configured) return true;
|
|
5883
|
+
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
|
5884
|
+
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
|
5885
|
+
if (!publicKey || !privateKey) return false;
|
|
5886
|
+
webpush.setVapidDetails(process.env.VAPID_SUBJECT || "mailto:support@example.com", publicKey, privateKey);
|
|
5887
|
+
configured = true;
|
|
5888
|
+
return true;
|
|
5889
|
+
}
|
|
5890
|
+
function getVapidPublicKey() {
|
|
5891
|
+
return process.env.VAPID_PUBLIC_KEY || null;
|
|
5892
|
+
}
|
|
5893
|
+
async function sendPushToUser(payload, slugs, userId, notification) {
|
|
5894
|
+
if (!ensureVapid()) return { sent: 0 };
|
|
5895
|
+
let sent = 0;
|
|
5896
|
+
try {
|
|
5897
|
+
const subs = await dbFind(payload, slugs.pushSubscriptions, { where: { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true });
|
|
5898
|
+
for (const s of subs.docs) {
|
|
5899
|
+
const row = s;
|
|
5900
|
+
if (!row.endpoint || !row.p256dh || !row.auth) continue;
|
|
5901
|
+
try {
|
|
5902
|
+
await webpush.sendNotification(
|
|
5903
|
+
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|
|
5904
|
+
JSON.stringify(notification)
|
|
5905
|
+
);
|
|
5906
|
+
sent++;
|
|
5907
|
+
} catch (err) {
|
|
5908
|
+
const code = err?.statusCode;
|
|
5909
|
+
if (code === 404 || code === 410) {
|
|
5910
|
+
try {
|
|
5911
|
+
await dbDelete(payload, slugs.pushSubscriptions, { id: row.id, overrideAccess: true });
|
|
5912
|
+
} catch {
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
}
|
|
5916
|
+
}
|
|
5917
|
+
} catch (err) {
|
|
5918
|
+
console.error("[support] Push send failed:", err);
|
|
5919
|
+
}
|
|
5920
|
+
return { sent };
|
|
5921
|
+
}
|
|
5922
|
+
|
|
5923
|
+
// src/endpoints/push.ts
|
|
5924
|
+
function createVapidKeyEndpoint() {
|
|
5925
|
+
return {
|
|
5926
|
+
path: "/support/push/vapid-public-key",
|
|
5927
|
+
method: "get",
|
|
5928
|
+
handler: async () => Response.json({ publicKey: getVapidPublicKey() })
|
|
5929
|
+
};
|
|
5930
|
+
}
|
|
5931
|
+
function createPushSubscribeEndpoint(slugs) {
|
|
5932
|
+
return {
|
|
5933
|
+
path: "/support/push/subscribe",
|
|
5934
|
+
method: "post",
|
|
5935
|
+
handler: async (req) => {
|
|
5936
|
+
try {
|
|
5937
|
+
requireAdmin(req, slugs);
|
|
5938
|
+
let body;
|
|
5939
|
+
try {
|
|
5940
|
+
body = await req.json();
|
|
5941
|
+
} catch {
|
|
5942
|
+
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5943
|
+
}
|
|
5944
|
+
const sub = body?.subscription;
|
|
5945
|
+
const endpoint = sub?.endpoint;
|
|
5946
|
+
const p256dh = sub?.keys?.p256dh;
|
|
5947
|
+
const auth = sub?.keys?.auth;
|
|
5948
|
+
if (!endpoint || !p256dh || !auth) {
|
|
5949
|
+
return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
|
|
5950
|
+
}
|
|
5951
|
+
const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
|
|
5952
|
+
const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
|
|
5953
|
+
if (existing.docs.length > 0) {
|
|
5954
|
+
await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
|
|
5955
|
+
} else {
|
|
5956
|
+
await dbCreate(req.payload, slugs.pushSubscriptions, { data, overrideAccess: true });
|
|
5957
|
+
}
|
|
5958
|
+
return Response.json({ ok: true });
|
|
5959
|
+
} catch (error) {
|
|
5960
|
+
const authResponse = handleAuthError(error);
|
|
5961
|
+
if (authResponse) return authResponse;
|
|
5962
|
+
console.error("[push] Error:", error);
|
|
5963
|
+
return Response.json({ error: "Erreur interne" }, { status: 500 });
|
|
5964
|
+
}
|
|
5965
|
+
}
|
|
5966
|
+
};
|
|
5967
|
+
}
|
|
5876
5968
|
|
|
5877
5969
|
// src/endpoints/user-prefs.ts
|
|
5878
5970
|
var PREF_KEY_PREFIX = "support-user-prefs";
|
|
@@ -6500,6 +6592,8 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6500
6592
|
if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs));
|
|
6501
6593
|
endpoints.push(createProcessDigestsEndpoint(slugs));
|
|
6502
6594
|
endpoints.push(createChannelsWebhookEndpoint(slugs));
|
|
6595
|
+
endpoints.push(createVapidKeyEndpoint());
|
|
6596
|
+
endpoints.push(createPushSubscribeEndpoint(slugs));
|
|
6503
6597
|
return endpoints;
|
|
6504
6598
|
}
|
|
6505
6599
|
|
|
@@ -6821,6 +6915,20 @@ async function resolveSlaPolicy(payload, ticket, slugs) {
|
|
|
6821
6915
|
}
|
|
6822
6916
|
}
|
|
6823
6917
|
const priority = field(ticket, "priority") || "normal";
|
|
6918
|
+
const teamRef = field(ticket, "team");
|
|
6919
|
+
const teamId = typeof teamRef === "object" && teamRef !== null ? teamRef.id : teamRef;
|
|
6920
|
+
if (teamId) {
|
|
6921
|
+
try {
|
|
6922
|
+
const teamPolicies = await dbFind(payload, slugs.slaPolicies, {
|
|
6923
|
+
where: { and: [{ team: { equals: teamId } }, { priority: { equals: priority } }] },
|
|
6924
|
+
limit: 1,
|
|
6925
|
+
depth: 0,
|
|
6926
|
+
overrideAccess: true
|
|
6927
|
+
});
|
|
6928
|
+
if (teamPolicies.docs.length > 0) return teamPolicies.docs[0];
|
|
6929
|
+
} catch {
|
|
6930
|
+
}
|
|
6931
|
+
}
|
|
6824
6932
|
try {
|
|
6825
6933
|
const defaults = await dbFind(payload, slugs.slaPolicies, {
|
|
6826
6934
|
where: {
|
|
@@ -7177,7 +7285,7 @@ function createRestrictClientUpdates(slugs) {
|
|
|
7177
7285
|
return async ({ data, operation, req, originalDoc }) => {
|
|
7178
7286
|
if (operation !== "update") return data;
|
|
7179
7287
|
if (req.user?.collection !== slugs.supportClients) return data;
|
|
7180
|
-
const allowedStatuses = ["open", "resolved"];
|
|
7288
|
+
const allowedStatuses = ["open", "waiting_support", "resolved"];
|
|
7181
7289
|
const newData = {};
|
|
7182
7290
|
if (data.status && allowedStatuses.includes(data.status)) {
|
|
7183
7291
|
newData.status = data.status;
|
|
@@ -8203,6 +8311,15 @@ function createNotifyAdminOnClientMessage(slugs, notificationSlug) {
|
|
|
8203
8311
|
}
|
|
8204
8312
|
const preview = doc.body?.length > 500 ? doc.body.slice(0, 500) + "..." : doc.body;
|
|
8205
8313
|
const headerTitle = isNewTicket ? `Nouveau ticket ${ticketNumber}` : `Nouveau message \u2014 ${ticketNumber}`;
|
|
8314
|
+
const assignedId = assignedAdmin?.id ?? (ticket.assignedTo && typeof ticket.assignedTo !== "object" ? ticket.assignedTo : void 0);
|
|
8315
|
+
if (assignedId) {
|
|
8316
|
+
void sendPushToUser(payload, slugs, assignedId, {
|
|
8317
|
+
title: headerTitle,
|
|
8318
|
+
body: `${clientName}: ${preview || subject}`,
|
|
8319
|
+
url: `/admin/collections/${slugs.tickets}/${ticketId}`
|
|
8320
|
+
}).catch(() => {
|
|
8321
|
+
});
|
|
8322
|
+
}
|
|
8206
8323
|
if (primaryEmail) {
|
|
8207
8324
|
await payload.sendEmail({
|
|
8208
8325
|
// INTERNAL email (to the support team) — recipients are the contact
|
|
@@ -9806,6 +9923,16 @@ function createSlaPoliciesCollection(slugs) {
|
|
|
9806
9923
|
admin: {
|
|
9807
9924
|
description: "Utiliser cette politique pour les tickets sans SLA sp\xE9cifique (par priorit\xE9)"
|
|
9808
9925
|
}
|
|
9926
|
+
},
|
|
9927
|
+
{
|
|
9928
|
+
name: "team",
|
|
9929
|
+
type: "relationship",
|
|
9930
|
+
relationTo: slugs.supportTeams,
|
|
9931
|
+
index: true,
|
|
9932
|
+
label: "\xC9quipe (optionnel)",
|
|
9933
|
+
admin: {
|
|
9934
|
+
description: "Limiter cette politique \xE0 une \xE9quipe \u2014 prioritaire sur la politique par d\xE9faut"
|
|
9935
|
+
}
|
|
9809
9936
|
}
|
|
9810
9937
|
],
|
|
9811
9938
|
access: {
|
|
@@ -10448,6 +10575,30 @@ function createSupportTeamCollection(slugs) {
|
|
|
10448
10575
|
};
|
|
10449
10576
|
}
|
|
10450
10577
|
|
|
10578
|
+
// src/collections/PushSubscription.ts
|
|
10579
|
+
function createPushSubscriptionCollection(slugs) {
|
|
10580
|
+
return {
|
|
10581
|
+
slug: slugs.pushSubscriptions,
|
|
10582
|
+
labels: { singular: "Abonnement push", plural: "Abonnements push" },
|
|
10583
|
+
admin: { hidden: true, group: "Support", defaultColumns: ["user", "endpoint", "createdAt"] },
|
|
10584
|
+
access: {
|
|
10585
|
+
create: () => false,
|
|
10586
|
+
// written via the subscribe endpoint (overrideAccess)
|
|
10587
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
10588
|
+
update: () => false,
|
|
10589
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10590
|
+
},
|
|
10591
|
+
fields: [
|
|
10592
|
+
{ name: "user", type: "relationship", relationTo: slugs.users, index: true, label: "Agent" },
|
|
10593
|
+
{ name: "endpoint", type: "text", required: true, index: true, label: "Endpoint" },
|
|
10594
|
+
{ name: "p256dh", type: "text", label: "Cl\xE9 p256dh" },
|
|
10595
|
+
{ name: "auth", type: "text", label: "Cl\xE9 auth" },
|
|
10596
|
+
{ name: "userAgent", type: "text", label: "User-Agent" }
|
|
10597
|
+
],
|
|
10598
|
+
timestamps: true
|
|
10599
|
+
};
|
|
10600
|
+
}
|
|
10601
|
+
|
|
10451
10602
|
// src/plugin.ts
|
|
10452
10603
|
function viewConfig(component, path) {
|
|
10453
10604
|
return { Component: component, path };
|
|
@@ -10486,7 +10637,8 @@ function supportPlugin(config) {
|
|
|
10486
10637
|
createTicketCollaboratorsCollection(slugs),
|
|
10487
10638
|
createNotificationQueueCollection(slugs),
|
|
10488
10639
|
createAutomationRulesCollection(slugs),
|
|
10489
|
-
createSupportTeamCollection(slugs)
|
|
10640
|
+
createSupportTeamCollection(slugs),
|
|
10641
|
+
createPushSubscriptionCollection(slugs)
|
|
10490
10642
|
];
|
|
10491
10643
|
if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs));
|
|
10492
10644
|
if (features.timeTracking !== false) supportCollections.push(createTimeEntriesCollection(slugs));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consilioweb/payload-support",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
|
@@ -54,7 +54,9 @@
|
|
|
54
54
|
"typecheck": "tsc --noEmit",
|
|
55
55
|
"prepublishOnly": "npm run build",
|
|
56
56
|
"test": "vitest run",
|
|
57
|
-
"test:watch": "vitest"
|
|
57
|
+
"test:watch": "vitest",
|
|
58
|
+
"test:e2e": "playwright test",
|
|
59
|
+
"test:e2e:ui": "playwright test --ui"
|
|
58
60
|
},
|
|
59
61
|
"keywords": [
|
|
60
62
|
"payload",
|
|
@@ -123,6 +125,7 @@
|
|
|
123
125
|
},
|
|
124
126
|
"devDependencies": {
|
|
125
127
|
"@payloadcms/db-sqlite": "^3.85.1",
|
|
128
|
+
"@playwright/test": "^1.49.0",
|
|
126
129
|
"@payloadcms/graphql": "^3.85.1",
|
|
127
130
|
"@payloadcms/next": "^3.85.1",
|
|
128
131
|
"@payloadcms/richtext-lexical": "^3.85.1",
|
|
@@ -131,6 +134,7 @@
|
|
|
131
134
|
"@types/pdfkit": "^0.17.6",
|
|
132
135
|
"@types/react": "^19.0.0",
|
|
133
136
|
"@types/sanitize-html": "^2.16.1",
|
|
137
|
+
"@types/web-push": "^3.6.4",
|
|
134
138
|
"esbuild-sass-plugin": "^3.7.0",
|
|
135
139
|
"next": "^15.5.14",
|
|
136
140
|
"payload": "^3.85.1",
|
|
@@ -142,6 +146,7 @@
|
|
|
142
146
|
},
|
|
143
147
|
"dependencies": {
|
|
144
148
|
"pdfkit": "^0.19.1",
|
|
145
|
-
"sanitize-html": "^2.17.5"
|
|
149
|
+
"sanitize-html": "^2.17.5",
|
|
150
|
+
"web-push": "^3.6.7"
|
|
146
151
|
}
|
|
147
152
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
import { createAdminStatsEndpoint } from '../../endpoints/admin-stats'
|
|
4
|
+
import { resolveSlugs } from '../../utils/slugs'
|
|
5
|
+
|
|
6
|
+
const slugs = resolveSlugs({ users: 'users' })
|
|
7
|
+
const PW = 'test-pw-12345'
|
|
8
|
+
|
|
9
|
+
/** Per-team SLA policies & dashboards (roadmap). */
|
|
10
|
+
describe('per-team SLA & dashboards (roadmap)', () => {
|
|
11
|
+
it('uses the team-specific SLA policy over the global default', async () => {
|
|
12
|
+
const payload = await buildTestPayload()
|
|
13
|
+
const team = await payload.create({ collection: 'support-teams', data: { name: 'Team SLA' }, overrideAccess: true })
|
|
14
|
+
// Team policy: 2h resolution. Global default: 24h.
|
|
15
|
+
await payload.create({ collection: 'sla-policies', data: { name: 'Team', priority: 'normal', firstResponseTime: 30, resolutionTime: 120, businessHoursOnly: false, team: team.id }, overrideAccess: true })
|
|
16
|
+
await payload.create({ collection: 'sla-policies', data: { name: 'Default', priority: 'normal', firstResponseTime: 120, resolutionTime: 1440, businessHoursOnly: false, isDefault: true }, overrideAccess: true })
|
|
17
|
+
|
|
18
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'pts@example.com', password: PW, firstName: 'P', lastName: 'T', company: 'C' }, overrideAccess: true })
|
|
19
|
+
const t = await payload.create({ collection: 'tickets', data: { subject: 'team sla', client: c.id, status: 'open', priority: 'normal', team: team.id }, overrideAccess: true })
|
|
20
|
+
|
|
21
|
+
const reread = await payload.findByID({ collection: 'tickets', id: t.id, overrideAccess: true })
|
|
22
|
+
const created = new Date(reread.createdAt as string).getTime()
|
|
23
|
+
const diffMin = (new Date(reread.slaResolutionDue as string).getTime() - created) / 60000
|
|
24
|
+
expect(diffMin).toBeGreaterThan(110) // ~120 (team policy), NOT 1440 (default)
|
|
25
|
+
expect(diffMin).toBeLessThan(130)
|
|
26
|
+
}, 60_000)
|
|
27
|
+
|
|
28
|
+
it('scopes the dashboard counts to a team via ?teamId', async () => {
|
|
29
|
+
const payload = await buildTestPayload()
|
|
30
|
+
const teamC = await payload.create({ collection: 'support-teams', data: { name: 'Team C' }, overrideAccess: true })
|
|
31
|
+
const teamD = await payload.create({ collection: 'support-teams', data: { name: 'Team D' }, overrideAccess: true })
|
|
32
|
+
const c = await payload.create({ collection: 'support-clients', data: { email: 'ptd@example.com', password: PW, firstName: 'P', lastName: 'D', company: 'C' }, overrideAccess: true })
|
|
33
|
+
await payload.create({ collection: 'tickets', data: { subject: 'c1', client: c.id, status: 'open', priority: 'normal', team: teamC.id }, overrideAccess: true })
|
|
34
|
+
await payload.create({ collection: 'tickets', data: { subject: 'c2', client: c.id, status: 'open', priority: 'normal', team: teamC.id }, overrideAccess: true })
|
|
35
|
+
await payload.create({ collection: 'tickets', data: { subject: 'd1', client: c.id, status: 'open', priority: 'normal', team: teamD.id }, overrideAccess: true })
|
|
36
|
+
|
|
37
|
+
const admin = await payload.create({ collection: 'users', data: { email: 'pt-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
38
|
+
const ep = createAdminStatsEndpoint(slugs)
|
|
39
|
+
const req = { payload, user: { ...admin, collection: 'users' }, url: `http://localhost/api/support/admin-stats?teamId=${teamC.id}`, headers: new Headers({}) }
|
|
40
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
41
|
+
const json = await res.json()
|
|
42
|
+
expect(json.total).toBe(2) // only Team C's tickets
|
|
43
|
+
}, 60_000)
|
|
44
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildTestPayload } from './buildTestPayload'
|
|
3
|
+
import { createVapidKeyEndpoint, createPushSubscribeEndpoint } from '../../endpoints/push'
|
|
4
|
+
import { sendPushToUser } from '../../utils/push'
|
|
5
|
+
import { resolveSlugs } from '../../utils/slugs'
|
|
6
|
+
|
|
7
|
+
const slugs = resolveSlugs({ users: 'users' })
|
|
8
|
+
const PW = 'test-pw-12345'
|
|
9
|
+
|
|
10
|
+
/** Native push / browser notifications (roadmap). */
|
|
11
|
+
describe('push notifications (roadmap)', () => {
|
|
12
|
+
it('subscribe stores a subscription row for the agent', async () => {
|
|
13
|
+
const payload = await buildTestPayload()
|
|
14
|
+
const admin = await payload.create({ collection: 'users', data: { email: 'push-admin@example.com', password: PW } as never, overrideAccess: true })
|
|
15
|
+
const ep = createPushSubscribeEndpoint(slugs)
|
|
16
|
+
const req = {
|
|
17
|
+
payload,
|
|
18
|
+
user: { ...admin, collection: 'users' },
|
|
19
|
+
headers: new Headers({ 'user-agent': 'vitest' }),
|
|
20
|
+
json: async () => ({ subscription: { endpoint: 'https://push.example.com/abc', keys: { p256dh: 'pk', auth: 'ak' } } }),
|
|
21
|
+
}
|
|
22
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
23
|
+
expect(res.status).toBe(200)
|
|
24
|
+
|
|
25
|
+
const stored = await payload.find({ collection: 'push-subscriptions', where: { endpoint: { equals: 'https://push.example.com/abc' } }, overrideAccess: true })
|
|
26
|
+
expect(stored.docs.length).toBe(1)
|
|
27
|
+
expect((stored.docs[0] as { auth?: string }).auth).toBe('ak')
|
|
28
|
+
}, 60_000)
|
|
29
|
+
|
|
30
|
+
it('subscribe is idempotent on endpoint (upsert, no duplicate)', async () => {
|
|
31
|
+
const payload = await buildTestPayload()
|
|
32
|
+
const admin = await payload.create({ collection: 'users', data: { email: 'push-admin2@example.com', password: PW } as never, overrideAccess: true })
|
|
33
|
+
const ep = createPushSubscribeEndpoint(slugs)
|
|
34
|
+
const mk = (auth: string) => ({
|
|
35
|
+
payload,
|
|
36
|
+
user: { ...admin, collection: 'users' },
|
|
37
|
+
headers: new Headers({}),
|
|
38
|
+
json: async () => ({ subscription: { endpoint: 'https://push.example.com/dup', keys: { p256dh: 'pk', auth } } }),
|
|
39
|
+
})
|
|
40
|
+
await (ep.handler as (r: unknown) => Promise<Response>)(mk('first'))
|
|
41
|
+
await (ep.handler as (r: unknown) => Promise<Response>)(mk('second'))
|
|
42
|
+
const stored = await payload.find({ collection: 'push-subscriptions', where: { endpoint: { equals: 'https://push.example.com/dup' } }, overrideAccess: true })
|
|
43
|
+
expect(stored.docs.length).toBe(1)
|
|
44
|
+
expect((stored.docs[0] as { auth?: string }).auth).toBe('second')
|
|
45
|
+
}, 60_000)
|
|
46
|
+
|
|
47
|
+
it('subscribe rejects an anonymous request', async () => {
|
|
48
|
+
const payload = await buildTestPayload()
|
|
49
|
+
const ep = createPushSubscribeEndpoint(slugs)
|
|
50
|
+
const req = {
|
|
51
|
+
payload,
|
|
52
|
+
user: null,
|
|
53
|
+
headers: new Headers({}),
|
|
54
|
+
json: async () => ({ subscription: { endpoint: 'https://push.example.com/x', keys: { p256dh: 'pk', auth: 'ak' } } }),
|
|
55
|
+
}
|
|
56
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)(req)
|
|
57
|
+
expect(res.status).toBe(401)
|
|
58
|
+
}, 60_000)
|
|
59
|
+
|
|
60
|
+
it('vapid-public-key returns null when VAPID is not configured', async () => {
|
|
61
|
+
delete process.env.VAPID_PUBLIC_KEY
|
|
62
|
+
const ep = createVapidKeyEndpoint()
|
|
63
|
+
const res = await (ep.handler as (r: unknown) => Promise<Response>)({})
|
|
64
|
+
const json = await res.json()
|
|
65
|
+
expect(json.publicKey).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('sendPushToUser no-ops without VAPID keys', async () => {
|
|
69
|
+
delete process.env.VAPID_PUBLIC_KEY
|
|
70
|
+
delete process.env.VAPID_PRIVATE_KEY
|
|
71
|
+
const payload = await buildTestPayload()
|
|
72
|
+
const result = await sendPushToUser(payload, slugs, 1, { title: 'Hi', body: 'There' })
|
|
73
|
+
expect(result.sent).toBe(0)
|
|
74
|
+
}, 60_000)
|
|
75
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CollectionConfig } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Web Push subscriptions for agents (browser notifications). Written by the
|
|
6
|
+
* /support/push/subscribe endpoint and drained by `sendPushToUser`.
|
|
7
|
+
*/
|
|
8
|
+
export function createPushSubscriptionCollection(slugs: CollectionSlugs): CollectionConfig {
|
|
9
|
+
return {
|
|
10
|
+
slug: slugs.pushSubscriptions,
|
|
11
|
+
labels: { singular: 'Abonnement push', plural: 'Abonnements push' },
|
|
12
|
+
admin: { hidden: true, group: 'Support', defaultColumns: ['user', 'endpoint', 'createdAt'] },
|
|
13
|
+
access: {
|
|
14
|
+
create: () => false, // written via the subscribe endpoint (overrideAccess)
|
|
15
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
16
|
+
update: () => false,
|
|
17
|
+
delete: ({ req }) => req.user?.collection === slugs.users,
|
|
18
|
+
},
|
|
19
|
+
fields: [
|
|
20
|
+
{ name: 'user', type: 'relationship', relationTo: slugs.users, index: true, label: 'Agent' },
|
|
21
|
+
{ name: 'endpoint', type: 'text', required: true, index: true, label: 'Endpoint' },
|
|
22
|
+
{ name: 'p256dh', type: 'text', label: 'Clé p256dh' },
|
|
23
|
+
{ name: 'auth', type: 'text', label: 'Clé auth' },
|
|
24
|
+
{ name: 'userAgent', type: 'text', label: 'User-Agent' },
|
|
25
|
+
],
|
|
26
|
+
timestamps: true,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -111,6 +111,16 @@ export function createSlaPoliciesCollection(slugs: CollectionSlugs): CollectionC
|
|
|
111
111
|
description: 'Utiliser cette politique pour les tickets sans SLA spécifique (par priorité)',
|
|
112
112
|
},
|
|
113
113
|
},
|
|
114
|
+
{
|
|
115
|
+
name: 'team',
|
|
116
|
+
type: 'relationship',
|
|
117
|
+
relationTo: slugs.supportTeams,
|
|
118
|
+
index: true,
|
|
119
|
+
label: 'Équipe (optionnel)',
|
|
120
|
+
admin: {
|
|
121
|
+
description: "Limiter cette politique à une équipe — prioritaire sur la politique par défaut",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
114
124
|
],
|
|
115
125
|
access: {
|
|
116
126
|
create: ({ req }) => req.user?.collection === slugs.users,
|
|
@@ -9,6 +9,7 @@ import { createCheckSlaOnReply } from '../hooks/checkSLA'
|
|
|
9
9
|
import { resolveAccessibleTicketIds } from '../utils/ticketAccess'
|
|
10
10
|
import { sanitizeMessageHtml } from '../utils/sanitizeHtml'
|
|
11
11
|
import { queueClientNotification } from '../utils/notificationQueue'
|
|
12
|
+
import { sendPushToUser } from '../utils/push'
|
|
12
13
|
import { dbFind, dbCreate, dbFindByID } from '../utils/db'
|
|
13
14
|
|
|
14
15
|
function createAssignAuthor(slugs: CollectionSlugs): CollectionBeforeChangeHook {
|
|
@@ -260,6 +261,16 @@ function createNotifyAdminOnClientMessage(slugs: CollectionSlugs, notificationSl
|
|
|
260
261
|
const preview = doc.body?.length > 500 ? doc.body.slice(0, 500) + '...' : doc.body
|
|
261
262
|
const headerTitle = isNewTicket ? `Nouveau ticket ${ticketNumber}` : `Nouveau message — ${ticketNumber}`
|
|
262
263
|
|
|
264
|
+
// Browser push to the assigned agent (RUNTIME no-op without VAPID keys).
|
|
265
|
+
const assignedId = assignedAdmin?.id ?? (ticket.assignedTo && typeof ticket.assignedTo !== 'object' ? ticket.assignedTo : undefined)
|
|
266
|
+
if (assignedId) {
|
|
267
|
+
void sendPushToUser(payload, slugs, assignedId, {
|
|
268
|
+
title: headerTitle,
|
|
269
|
+
body: `${clientName}: ${preview || subject}`,
|
|
270
|
+
url: `/admin/collections/${slugs.tickets}/${ticketId}`,
|
|
271
|
+
}).catch(() => { /* best-effort */ })
|
|
272
|
+
}
|
|
273
|
+
|
|
263
274
|
if (primaryEmail) {
|
|
264
275
|
await payload.sendEmail({
|
|
265
276
|
// INTERNAL email (to the support team) — recipients are the contact
|
|
@@ -87,7 +87,10 @@ function createRestrictClientUpdates(slugs: CollectionSlugs): CollectionBeforeCh
|
|
|
87
87
|
return async ({ data, operation, req, originalDoc }) => {
|
|
88
88
|
if (operation !== 'update') return data
|
|
89
89
|
if (req.user?.collection !== slugs.supportClients) return data
|
|
90
|
-
|
|
90
|
+
// Clients may move a ticket to: open / waiting_support (reopen) or resolved (close).
|
|
91
|
+
// 'waiting_support' is required so the reopen button actually puts the ball back
|
|
92
|
+
// in the support team's court instead of being silently rejected.
|
|
93
|
+
const allowedStatuses = ['open', 'waiting_support', 'resolved']
|
|
91
94
|
const newData: Record<string, unknown> = {}
|
|
92
95
|
if (data.status && allowedStatuses.includes(data.status as string)) {
|
|
93
96
|
newData.status = data.status
|
package/src/collections/index.ts
CHANGED
|
@@ -20,3 +20,4 @@ export { createTicketCollaboratorsCollection } from './TicketCollaborators'
|
|
|
20
20
|
export { createNotificationQueueCollection } from './NotificationQueue'
|
|
21
21
|
export { createAutomationRulesCollection } from './AutomationRules'
|
|
22
22
|
export { createSupportTeamCollection } from './SupportTeam'
|
|
23
|
+
export { createPushSubscriptionCollection } from './PushSubscription'
|
|
@@ -18,12 +18,16 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
18
18
|
|
|
19
19
|
requireAdmin(req, slugs)
|
|
20
20
|
|
|
21
|
+
// Optional per-team dashboard scoping (?teamId=) — applied to ticket counts.
|
|
22
|
+
const teamId = new URL(req.url!).searchParams.get('teamId')
|
|
23
|
+
const teamAnd = teamId ? [{ team: { equals: Number(teamId) } }] : []
|
|
24
|
+
|
|
21
25
|
// ── Status counts via individual count queries ──
|
|
22
26
|
const statuses = ['open', 'waiting_client', 'resolved'] as const
|
|
23
27
|
const statusCounts = await Promise.all(
|
|
24
28
|
statuses.map(async (status) => {
|
|
25
29
|
const result = await dbCount(payload, slugs.tickets, {
|
|
26
|
-
where: { status: { equals: status } },
|
|
30
|
+
where: { and: [{ status: { equals: status } }, ...teamAnd] },
|
|
27
31
|
overrideAccess: true,
|
|
28
32
|
})
|
|
29
33
|
return [status, result.totalDocs] as const
|
|
@@ -37,7 +41,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
37
41
|
const priorityCounts = await Promise.all(
|
|
38
42
|
priorities.map(async (priority) => {
|
|
39
43
|
const result = await dbCount(payload, slugs.tickets, {
|
|
40
|
-
where: { priority: { equals: priority } },
|
|
44
|
+
where: { and: [{ priority: { equals: priority } }, ...teamAnd] },
|
|
41
45
|
overrideAccess: true,
|
|
42
46
|
})
|
|
43
47
|
return [priority, result.totalDocs] as const
|
|
@@ -50,7 +54,7 @@ export function createAdminStatsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
50
54
|
const categoryCounts = await Promise.all(
|
|
51
55
|
categories.map(async (category) => {
|
|
52
56
|
const result = await dbCount(payload, slugs.tickets, {
|
|
53
|
-
where: { category: { equals: category } },
|
|
57
|
+
where: { and: [{ category: { equals: category } }, ...teamAnd] },
|
|
54
58
|
overrideAccess: true,
|
|
55
59
|
})
|
|
56
60
|
return [category, result.totalDocs] as const
|
package/src/endpoints/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { createProcessScheduledEndpoint } from './process-scheduled'
|
|
|
48
48
|
import { createProcessSnoozeEndpoint } from './process-snooze'
|
|
49
49
|
import { createProcessDigestsEndpoint } from './process-digests'
|
|
50
50
|
import { createChannelsWebhookEndpoint } from './channels'
|
|
51
|
+
import { createVapidKeyEndpoint, createPushSubscribeEndpoint } from './push'
|
|
51
52
|
import { createUserPrefsGetEndpoint, createUserPrefsPostEndpoint } from './user-prefs'
|
|
52
53
|
import { createEscalateEndpoint } from './escalate'
|
|
53
54
|
import { createTicketFeedbackEndpoint } from './ticket-feedback'
|
|
@@ -100,6 +101,7 @@ export { createProcessScheduledEndpoint } from './process-scheduled'
|
|
|
100
101
|
export { createProcessSnoozeEndpoint } from './process-snooze'
|
|
101
102
|
export { createProcessDigestsEndpoint } from './process-digests'
|
|
102
103
|
export { createChannelsWebhookEndpoint } from './channels'
|
|
104
|
+
export { createVapidKeyEndpoint, createPushSubscribeEndpoint } from './push'
|
|
103
105
|
export { createUserPrefsGetEndpoint, createUserPrefsPostEndpoint } from './user-prefs'
|
|
104
106
|
export type { UserPrefs } from './user-prefs'
|
|
105
107
|
export { createEscalateEndpoint } from './escalate'
|
|
@@ -193,6 +195,8 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
|
|
|
193
195
|
if (!f || f.snooze !== false) endpoints.push(createProcessSnoozeEndpoint(slugs))
|
|
194
196
|
endpoints.push(createProcessDigestsEndpoint(slugs))
|
|
195
197
|
endpoints.push(createChannelsWebhookEndpoint(slugs))
|
|
198
|
+
endpoints.push(createVapidKeyEndpoint())
|
|
199
|
+
endpoints.push(createPushSubscribeEndpoint(slugs))
|
|
196
200
|
|
|
197
201
|
return endpoints
|
|
198
202
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { dbFind, dbCreate, dbUpdate } from '../utils/db'
|
|
5
|
+
import { getVapidPublicKey } from '../utils/push'
|
|
6
|
+
|
|
7
|
+
/** GET /api/support/push/vapid-public-key — public key the browser uses to subscribe. */
|
|
8
|
+
export function createVapidKeyEndpoint(): Endpoint {
|
|
9
|
+
return {
|
|
10
|
+
path: '/support/push/vapid-public-key',
|
|
11
|
+
method: 'get',
|
|
12
|
+
handler: async () => Response.json({ publicKey: getVapidPublicKey() }),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** POST /api/support/push/subscribe — register the current agent's browser push subscription. */
|
|
17
|
+
export function createPushSubscribeEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
18
|
+
return {
|
|
19
|
+
path: '/support/push/subscribe',
|
|
20
|
+
method: 'post',
|
|
21
|
+
handler: async (req) => {
|
|
22
|
+
try {
|
|
23
|
+
requireAdmin(req, slugs)
|
|
24
|
+
let body: { subscription?: { endpoint?: string; keys?: { p256dh?: string; auth?: string } } }
|
|
25
|
+
try { body = (await req.json!()) as typeof body } catch { return Response.json({ error: 'Invalid JSON body' }, { status: 400 }) }
|
|
26
|
+
const sub = body?.subscription
|
|
27
|
+
const endpoint = sub?.endpoint
|
|
28
|
+
const p256dh = sub?.keys?.p256dh
|
|
29
|
+
const auth = sub?.keys?.auth
|
|
30
|
+
if (!endpoint || !p256dh || !auth) {
|
|
31
|
+
return Response.json({ error: 'subscription invalide (endpoint + keys requis).' }, { status: 400 })
|
|
32
|
+
}
|
|
33
|
+
const data = { user: req.user!.id, endpoint, p256dh, auth, userAgent: req.headers.get('user-agent') || '' }
|
|
34
|
+
const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true })
|
|
35
|
+
if (existing.docs.length > 0) {
|
|
36
|
+
await dbUpdate(req.payload, slugs.pushSubscriptions, { id: (existing.docs[0] as { id: number | string }).id, data, overrideAccess: true })
|
|
37
|
+
} else {
|
|
38
|
+
await dbCreate(req.payload, slugs.pushSubscriptions, { data, overrideAccess: true })
|
|
39
|
+
}
|
|
40
|
+
return Response.json({ ok: true })
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const authResponse = handleAuthError(error)
|
|
43
|
+
if (authResponse) return authResponse
|
|
44
|
+
console.error('[push] Error:', error)
|
|
45
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/hooks/checkSLA.ts
CHANGED
|
@@ -157,8 +157,22 @@ async function resolveSlaPolicy(
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
// Look for a default policy matching this ticket's priority
|
|
161
160
|
const priority = field(ticket, 'priority') || 'normal'
|
|
161
|
+
|
|
162
|
+
// Per-team policy (team + priority) takes precedence over the global default.
|
|
163
|
+
const teamRef = field(ticket, 'team')
|
|
164
|
+
const teamId = typeof teamRef === 'object' && teamRef !== null ? (teamRef as AnyDoc).id : teamRef
|
|
165
|
+
if (teamId) {
|
|
166
|
+
try {
|
|
167
|
+
const teamPolicies = await dbFind(payload, slugs.slaPolicies, {
|
|
168
|
+
where: { and: [{ team: { equals: teamId } }, { priority: { equals: priority } }] },
|
|
169
|
+
limit: 1, depth: 0, overrideAccess: true,
|
|
170
|
+
})
|
|
171
|
+
if (teamPolicies.docs.length > 0) return teamPolicies.docs[0]
|
|
172
|
+
} catch { /* fall through to default */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Look for a default policy matching this ticket's priority
|
|
162
176
|
try {
|
|
163
177
|
const defaults = await dbFind(payload, slugs.slaPolicies, {
|
|
164
178
|
where: {
|
package/src/plugin.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
createNotificationQueueCollection,
|
|
27
27
|
createAutomationRulesCollection,
|
|
28
28
|
createSupportTeamCollection,
|
|
29
|
+
createPushSubscriptionCollection,
|
|
29
30
|
} from './collections'
|
|
30
31
|
|
|
31
32
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
@@ -96,6 +97,7 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
96
97
|
createNotificationQueueCollection(slugs),
|
|
97
98
|
createAutomationRulesCollection(slugs),
|
|
98
99
|
createSupportTeamCollection(slugs),
|
|
100
|
+
createPushSubscriptionCollection(slugs),
|
|
99
101
|
]
|
|
100
102
|
|
|
101
103
|
// Auth logs (conditional)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { dbFind, dbDelete } from './db'
|
|
4
|
+
import webpush from 'web-push'
|
|
5
|
+
|
|
6
|
+
let configured = false
|
|
7
|
+
function ensureVapid(): boolean {
|
|
8
|
+
if (configured) return true
|
|
9
|
+
const publicKey = process.env.VAPID_PUBLIC_KEY
|
|
10
|
+
const privateKey = process.env.VAPID_PRIVATE_KEY
|
|
11
|
+
if (!publicKey || !privateKey) return false
|
|
12
|
+
webpush.setVapidDetails(process.env.VAPID_SUBJECT || 'mailto:support@example.com', publicKey, privateKey)
|
|
13
|
+
configured = true
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Public VAPID key for the browser to subscribe (null if push isn't configured). */
|
|
18
|
+
export function getVapidPublicKey(): string | null {
|
|
19
|
+
return process.env.VAPID_PUBLIC_KEY || null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PushNotification {
|
|
23
|
+
title: string
|
|
24
|
+
body: string
|
|
25
|
+
url?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Send a Web Push notification to all of an agent's subscriptions. RUNTIME-only:
|
|
30
|
+
* requires VAPID keys; without them it no-ops (`{ sent: 0 }`). Stale subscriptions
|
|
31
|
+
* (404/410) are pruned automatically.
|
|
32
|
+
*/
|
|
33
|
+
export async function sendPushToUser(
|
|
34
|
+
payload: Payload,
|
|
35
|
+
slugs: CollectionSlugs,
|
|
36
|
+
userId: number | string,
|
|
37
|
+
notification: PushNotification,
|
|
38
|
+
): Promise<{ sent: number }> {
|
|
39
|
+
if (!ensureVapid()) return { sent: 0 }
|
|
40
|
+
let sent = 0
|
|
41
|
+
try {
|
|
42
|
+
const subs = await dbFind(payload, slugs.pushSubscriptions, { where: { user: { equals: userId } }, limit: 100, depth: 0, overrideAccess: true })
|
|
43
|
+
for (const s of subs.docs) {
|
|
44
|
+
const row = s as { id: number | string; endpoint?: string; p256dh?: string; auth?: string }
|
|
45
|
+
if (!row.endpoint || !row.p256dh || !row.auth) continue
|
|
46
|
+
try {
|
|
47
|
+
await webpush.sendNotification(
|
|
48
|
+
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|
|
49
|
+
JSON.stringify(notification),
|
|
50
|
+
)
|
|
51
|
+
sent++
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const code = (err as { statusCode?: number })?.statusCode
|
|
54
|
+
if (code === 404 || code === 410) {
|
|
55
|
+
try { await dbDelete(payload, slugs.pushSubscriptions, { id: row.id, overrideAccess: true }) } catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error('[support] Push send failed:', err)
|
|
61
|
+
}
|
|
62
|
+
return { sent }
|
|
63
|
+
}
|
package/src/utils/slugs.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface CollectionSlugs {
|
|
|
23
23
|
notificationQueue: string
|
|
24
24
|
automationRules: string
|
|
25
25
|
supportTeams: string
|
|
26
|
+
pushSubscriptions: string
|
|
26
27
|
users: string
|
|
27
28
|
media: string
|
|
28
29
|
}
|
|
@@ -48,6 +49,7 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
|
|
|
48
49
|
notificationQueue: 'notification-queue',
|
|
49
50
|
automationRules: 'automation-rules',
|
|
50
51
|
supportTeams: 'support-teams',
|
|
52
|
+
pushSubscriptions: 'push-subscriptions',
|
|
51
53
|
users: 'users',
|
|
52
54
|
media: 'media',
|
|
53
55
|
}
|