@consilioweb/payload-support 1.0.0 → 1.1.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/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
 
6
6
  <div align="center">
7
7
 
8
+ [![npm version](https://img.shields.io/npm/v/@consilioweb/payload-support?color=1f8a5b&label=npm)](https://www.npmjs.com/package/@consilioweb/payload-support)
8
9
  [![MIT License](https://img.shields.io/badge/license-MIT-1f8a5b)](LICENSE)
9
- [![Version](https://img.shields.io/badge/version-1.0.0-1f8a5b)](package.json)
10
10
  [![Node.js](https://img.shields.io/badge/node-18+-1f8a5b)](https://nodejs.org)
11
11
  [![Payload](https://img.shields.io/badge/payload-3.x-1f8a5b)](https://payloadcms.com)
12
12
  [![Tests](https://img.shields.io/badge/tests-102%20passing-1f8a5b)](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
- - [ ] **Per-team SLA policies & dashboards**
409
- - [ ] **Native push / browser notifications**
410
- - [ ] **End-to-end UI test harness** (browser-driven admin)
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: {
@@ -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
@@ -240,6 +240,7 @@ interface CollectionSlugs {
240
240
  notificationQueue: string;
241
241
  automationRules: string;
242
242
  supportTeams: string;
243
+ pushSubscriptions: string;
243
244
  users: string;
244
245
  media: string;
245
246
  }
package/dist/index.d.ts CHANGED
@@ -240,6 +240,7 @@ interface CollectionSlugs {
240
240
  notificationQueue: string;
241
241
  automationRules: string;
242
242
  supportTeams: string;
243
+ pushSubscriptions: string;
243
244
  users: string;
244
245
  media: string;
245
246
  }
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: {
@@ -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.0.0",
3
+ "version": "1.1.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",
@@ -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
@@ -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
@@ -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
+ }
@@ -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
+ }
@@ -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
  }