Add Web Push notifications for backgrounded/frozen browser tabs
The SSE + in-page toast/sound system only works while the tab's JS is actually running — Chrome (and other browsers) freeze a backgrounded tab's JS after a stretch of inactivity to save power, so notifications silently stop regardless of how correct the SSE/toast code is. A Service Worker is the only mechanism that keeps receiving events independent of the tab's own lifecycle, so this adds a real Web Push pipeline: - New `push_subscriptions` table (one row per browser/device a user has subscribed from; endpoint is unique so re-subscribing overwrites rather than accumulating stale rows). - VAPID keypair config (lib/push/vapid.ts) — reads VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT from the environment; push is silently disabled (never throws) if they're unset, matching this codebase's existing "an optional integration outage must never break the core flow" pattern (see the LDAP auth comment). - public/sw.js: a minimal service worker — `push` -> showNotification, `notificationclick` -> focus or open the ticket. - API routes: GET /api/push/vapid-public-key (client needs it to call pushManager.subscribe), POST/DELETE /api/push/subscribe. - lib/push/client.ts: registers the service worker and subscribes, called from the notification bell after granting permission and again on mount for returning users who already granted it. - lib/tickets/service.ts: appendMessage() now also fires a push (fire- and-forget, never awaited by the caller) to every admin plus the ticket's assignee whenever a *customer* message arrives — same "who should know" rule as the in-page toast (lib/tickets/visibility.ts). Failed sends are inspected: a 404/410 (push service no longer recognizes the subscription) prunes the row; anything else is just logged, since it might be transient. Verified server-side end-to-end on this VM: saved a subscription via the API, created a customer ticket, and confirmed the push attempt actually fires (web-push validated and rejected a deliberately-malformed test key, proving the send path is wired correctly) without blocking or crashing ticket creation. Couldn't verify the full real-browser subscribe-and-receive path or an actual OS popup from here — this VM has no desktop/notification service, and headless Chromium's Notification permission can't be reliably granted in this sandbox (unrelated to the app code); that last mile needs verifying on a real machine. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
1 parent
6ddbc7e981
commit
b9a247c21c
14 files changed
+1457
-2
No files matched your search
Generated
+110
@@ -21,6 +21,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"telegraf": "^4.16.3",
|
"telegraf": "^4.16.3",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.12",
|
"eslint-config-next": "16.2.12",
|
||||||
@@ -2876,6 +2878,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.65.0",
|
"version": "8.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
||||||
@@ -3611,6 +3622,14 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -3824,6 +3843,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assert-plus": {
|
"node_modules/assert-plus": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||||
@@ -3975,6 +4005,11 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
@@ -4067,6 +4102,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
|
||||||
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="
|
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||||
|
},
|
||||||
"node_modules/buffer-fill": {
|
"node_modules/buffer-fill": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
|
||||||
@@ -4659,6 +4699,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.396",
|
"version": "1.5.396",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
|
||||||
@@ -5921,6 +5969,26 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
@@ -6562,6 +6630,25 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -7044,6 +7131,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -9881,6 +9973,24 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"telegraf": "^4.16.3",
|
"telegraf": "^4.16.3",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.12",
|
"eslint-config-next": "16.2.12",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Web Push service worker. Deliberately minimal — no offline caching, no
|
||||||
|
// asset interception. Its only job is to keep receiving push events even
|
||||||
|
// when the page's own tab is frozen or closed, and turn them into an OS
|
||||||
|
// notification.
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let payload = { title: "top-tickets", body: "", url: "/dashboard" };
|
||||||
|
try {
|
||||||
|
if (event.data) payload = { ...payload, ...event.data.json() };
|
||||||
|
} catch {
|
||||||
|
// malformed payload — fall back to the generic notification above
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(payload.title, {
|
||||||
|
body: payload.body,
|
||||||
|
icon: "/icon.svg",
|
||||||
|
data: { url: payload.url },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/dashboard";
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const clientsList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
const existing = clientsList.find((c) => "focus" in c);
|
||||||
|
if (existing) {
|
||||||
|
await existing.navigate(url);
|
||||||
|
await existing.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await self.clients.openWindow(url);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Bell, BellOff } from "lucide-react";
|
import { Bell, BellOff } from "lucide-react";
|
||||||
|
import { ensurePushSubscribed } from "@/lib/push/client";
|
||||||
|
|
||||||
function initialPermission(): NotificationPermission | "unsupported" {
|
function initialPermission(): NotificationPermission | "unsupported" {
|
||||||
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
||||||
@@ -11,11 +12,20 @@ function initialPermission(): NotificationPermission | "unsupported" {
|
|||||||
export function NotificationToggle() {
|
export function NotificationToggle() {
|
||||||
const [permission, setPermission] = useState(initialPermission);
|
const [permission, setPermission] = useState(initialPermission);
|
||||||
|
|
||||||
|
// Returning user who already granted permission in an earlier session —
|
||||||
|
// make sure the push subscription is (still) registered, not just the
|
||||||
|
// notification permission.
|
||||||
|
useEffect(() => {
|
||||||
|
if (permission === "granted") void ensurePushSubscribed();
|
||||||
|
}, [permission]);
|
||||||
|
|
||||||
if (permission === "unsupported") return null;
|
if (permission === "unsupported") return null;
|
||||||
|
|
||||||
async function requestPermission() {
|
async function requestPermission() {
|
||||||
if (permission === "granted") return; // no API to revoke it programmatically — only the browser's own site settings can
|
if (permission === "granted") return; // no API to revoke it programmatically — only the browser's own site settings can
|
||||||
setPermission(await Notification.requestPermission());
|
const next = await Notification.requestPermission();
|
||||||
|
setPermission(next);
|
||||||
|
if (next === "granted") void ensurePushSubscribed();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { savePushSubscription, deletePushSubscriptionByEndpoint } from "@/lib/push/service";
|
||||||
|
|
||||||
|
const subscribeSchema = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
keys: z.object({
|
||||||
|
p256dh: z.string().min(1),
|
||||||
|
auth: z.string().min(1),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = subscribeSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await savePushSubscription(session.user.id, parsed.data);
|
||||||
|
return NextResponse.json({ ok: true }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribeSchema = z.object({ endpoint: z.string().url() });
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = unsubscribeSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deletePushSubscriptionByEndpoint(parsed.data.endpoint);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { getVapidPublicKey } from "@/lib/push/vapid";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const publicKey = getVapidPublicKey();
|
||||||
|
if (!publicKey) {
|
||||||
|
return NextResponse.json({ error: "Push not configured" }, { status: 503 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ publicKey });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE `push_subscriptions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`endpoint` text NOT NULL,
|
||||||
|
`p256dh` text NOT NULL,
|
||||||
|
`auth` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `push_subscriptions_endpoint_unique` ON `push_subscriptions` (`endpoint`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1785138258070,
|
"when": 1785138258070,
|
||||||
"tag": "0005_colorful_gorilla_man",
|
"tag": "0005_colorful_gorilla_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1786130418783,
|
||||||
|
"tag": "0006_big_daredevil",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -210,3 +210,21 @@ export const ticketTags = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.ticketId, table.tagId] })],
|
(table) => [primaryKey({ columns: [table.ticketId, table.tagId] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per browser/device a user has subscribed to Web Push on — a user
|
||||||
|
* can have several (desktop + phone, multiple browsers, ...). `endpoint` is
|
||||||
|
* unique per subscription and doubles as a natural dedup key: re-subscribing
|
||||||
|
* from the same browser (e.g. after clearing permission and re-granting)
|
||||||
|
* overwrites the old keys rather than accumulating stale rows.
|
||||||
|
*/
|
||||||
|
export const pushSubscriptions = sqliteTable("push_subscriptions", {
|
||||||
|
id: id(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
endpoint: text("endpoint").notNull().unique(),
|
||||||
|
p256dh: text("p256dh").notNull(),
|
||||||
|
auth: text("auth").notNull(),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the service worker and makes sure a push subscription exists
|
||||||
|
* and is saved server-side. Best-effort only: quietly no-ops if push isn't
|
||||||
|
* supported, permission isn't granted, or anything in the chain fails —
|
||||||
|
* this is an enhancement on top of the in-page toast, never something a
|
||||||
|
* page load should be blocked or broken by.
|
||||||
|
*/
|
||||||
|
export async function ensurePushSubscribed(): Promise<void> {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||||
|
if (Notification.permission !== "granted") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register("/sw.js");
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (!subscription) {
|
||||||
|
const keyRes = await fetch("/api/push/vapid-public-key");
|
||||||
|
if (!keyRes.ok) return; // push not configured server-side yet
|
||||||
|
const { publicKey } = await keyRes.json();
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
// lib.dom's BufferSource typing is stricter than the runtime value
|
||||||
|
// actually needs to satisfy — this Uint8Array is always backed by a
|
||||||
|
// plain (non-shared) ArrayBuffer.
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch("/api/push/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(subscription.toJSON()),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort — a failure here must never disrupt the page
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { pushSubscriptions, users } from "@/lib/db/schema";
|
||||||
|
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
|
||||||
|
import { webpush, ensureVapidConfigured } from "./vapid";
|
||||||
|
|
||||||
|
export interface PushSubscriptionKeys {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-subscribing from the same browser (e.g. after clearing site data and re-granting) overwrites the old row via the endpoint's unique constraint, rather than accumulating stale duplicates. */
|
||||||
|
export async function savePushSubscription(userId: string, sub: PushSubscriptionKeys): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(pushSubscriptions)
|
||||||
|
.values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: pushSubscriptions.endpoint,
|
||||||
|
set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePushSubscriptionByEndpoint(endpoint: string): Promise<void> {
|
||||||
|
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise<void> {
|
||||||
|
if (userIds.length === 0 || !ensureVapidConfigured()) return;
|
||||||
|
|
||||||
|
const subs = await db.query.pushSubscriptions.findMany({ where: inArray(pushSubscriptions.userId, userIds) });
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
subs.map(async (sub) => {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
|
JSON.stringify(payload),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// A 404/410 means the push service no longer recognizes this
|
||||||
|
// subscription (browser unsubscribed, site data cleared, endpoint
|
||||||
|
// expired) — prune it so future sends stop failing on it. Any other
|
||||||
|
// error (a transient network blip, ...) is left alone to retry on
|
||||||
|
// the next event rather than deleting a possibly-still-good row.
|
||||||
|
const statusCode = (err as { statusCode?: number }).statusCode;
|
||||||
|
if (statusCode === 404 || statusCode === 410) {
|
||||||
|
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, sub.endpoint));
|
||||||
|
} else {
|
||||||
|
console.error("[push] send failed for", sub.endpoint, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes to every admin plus the ticket's assignee (if any) — the same
|
||||||
|
* "who should know about this" set the in-page toast uses (see
|
||||||
|
* lib/tickets/visibility.ts), so a subscribed device is notified even while
|
||||||
|
* its browser tab is frozen in the background. Callers only invoke this for
|
||||||
|
* customer-authored messages; an agent's own reply or internal note is the
|
||||||
|
* agent's own action, not a signal anyone needs pushed to them.
|
||||||
|
*/
|
||||||
|
export async function notifyPushForCustomerMessage(
|
||||||
|
ticket: TicketDTO,
|
||||||
|
message: MessageDTO,
|
||||||
|
ticketIsNew: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const admins = await db.query.users.findMany({ where: eq(users.role, "admin") });
|
||||||
|
const userIds = new Set(admins.map((u) => u.id));
|
||||||
|
if (ticket.assigneeId) userIds.add(ticket.assigneeId);
|
||||||
|
|
||||||
|
await sendPushToUsers([...userIds], {
|
||||||
|
title: ticketIsNew ? "Новая заявка" : `Сообщение от ${message.authorName}`,
|
||||||
|
body: ticketIsNew ? ticket.subject : message.body.slice(0, 120),
|
||||||
|
url: `/tickets/${ticket.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import webpush from "web-push";
|
||||||
|
|
||||||
|
let configured: boolean | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the web-push client once, lazily. Returns false (and leaves
|
||||||
|
* push silently disabled) if the VAPID env vars aren't set — a missing or
|
||||||
|
* incomplete config must never break ticket/message creation, the same way
|
||||||
|
* a directory outage must never break local login.
|
||||||
|
*/
|
||||||
|
export function ensureVapidConfigured(): boolean {
|
||||||
|
if (configured !== null) return configured;
|
||||||
|
|
||||||
|
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
||||||
|
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
||||||
|
const subject = process.env.VAPID_SUBJECT;
|
||||||
|
if (!publicKey || !privateKey || !subject) {
|
||||||
|
configured = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
|
configured = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVapidPublicKey(): string | null {
|
||||||
|
return process.env.VAPID_PUBLIC_KEY ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { webpush };
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ticketTags,
|
ticketTags,
|
||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
import { publishTicketEvent } from "@/lib/events/bus";
|
import { publishTicketEvent } from "@/lib/events/bus";
|
||||||
|
import { notifyPushForCustomerMessage } from "@/lib/push/service";
|
||||||
import type {
|
import type {
|
||||||
TicketDTO,
|
TicketDTO,
|
||||||
MessageDTO,
|
MessageDTO,
|
||||||
@@ -165,6 +166,14 @@ async function appendMessage(params: {
|
|||||||
message: messageDto,
|
message: messageDto,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget: push delivery is a best-effort side channel for
|
||||||
|
// backgrounded/frozen tabs, not part of the message-creation contract —
|
||||||
|
// callers (mail ingestion, the API routes) must not wait on or fail from
|
||||||
|
// a slow/unreachable push service.
|
||||||
|
if (params.authorType === "customer") {
|
||||||
|
void notifyPushForCustomerMessage(dto, messageDto, params.ticketIsNew);
|
||||||
|
}
|
||||||
|
|
||||||
return { ticket: dto, message: messageDto };
|
return { ticket: dto, message: messageDto };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user