From b9a247c21c0e8170538eec0f99b73588add05156 Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 7 Aug 2026 19:30:27 +0000 Subject: [PATCH] Add Web Push notifications for backgrounded/frozen browser tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- package-lock.json | 110 ++ package.json | 2 + public/sw.js | 39 + src/app/(admin)/notification-toggle.tsx | 14 +- src/app/api/push/subscribe/route.ts | 44 + src/app/api/push/vapid-public-key/route.ts | 16 + src/lib/db/migrations/0006_big_daredevil.sql | 11 + src/lib/db/migrations/meta/0006_snapshot.json | 1028 +++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 18 + src/lib/push/client.ts | 52 + src/lib/push/service.ts | 78 ++ src/lib/push/vapid.ts | 31 + src/lib/tickets/service.ts | 9 + 14 files changed, 1457 insertions(+), 2 deletions(-) create mode 100644 public/sw.js create mode 100644 src/app/api/push/subscribe/route.ts create mode 100644 src/app/api/push/vapid-public-key/route.ts create mode 100644 src/lib/db/migrations/0006_big_daredevil.sql create mode 100644 src/lib/db/migrations/meta/0006_snapshot.json create mode 100644 src/lib/push/client.ts create mode 100644 src/lib/push/service.ts create mode 100644 src/lib/push/vapid.ts diff --git a/package-lock.json b/package-lock.json index c4afe49..5610287 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "telegraf": "^4.16.3", + "web-push": "^3.6.7", "zod": "^4.4.3" }, "devDependencies": { @@ -32,6 +33,7 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.12", @@ -2876,6 +2878,15 @@ "@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": { "version": "8.65.0", "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" } }, + "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": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3824,6 +3843,17 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -3975,6 +4005,11 @@ "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": { "version": "1.1.16", "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", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", @@ -4659,6 +4699,14 @@ "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": { "version": "1.5.396", "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" } }, + "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": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -6562,6 +6630,25 @@ "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": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7044,6 +7131,11 @@ "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": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -9881,6 +9973,24 @@ "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index cdb9e36..4f16ab8 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "telegraf": "^4.16.3", + "web-push": "^3.6.7", "zod": "^4.4.3" }, "devDependencies": { @@ -38,6 +39,7 @@ "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.12", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..d91dcfd --- /dev/null +++ b/public/sw.js @@ -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); + })(), + ); +}); diff --git a/src/app/(admin)/notification-toggle.tsx b/src/app/(admin)/notification-toggle.tsx index c17e03e..5dbd994 100644 --- a/src/app/(admin)/notification-toggle.tsx +++ b/src/app/(admin)/notification-toggle.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Bell, BellOff } from "lucide-react"; +import { ensurePushSubscribed } from "@/lib/push/client"; function initialPermission(): NotificationPermission | "unsupported" { if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; @@ -11,11 +12,20 @@ function initialPermission(): NotificationPermission | "unsupported" { export function NotificationToggle() { 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; async function requestPermission() { 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 ( diff --git a/src/app/api/push/subscribe/route.ts b/src/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..57cdcf3 --- /dev/null +++ b/src/app/api/push/subscribe/route.ts @@ -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 }); +} diff --git a/src/app/api/push/vapid-public-key/route.ts b/src/app/api/push/vapid-public-key/route.ts new file mode 100644 index 0000000..fb72dcd --- /dev/null +++ b/src/app/api/push/vapid-public-key/route.ts @@ -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 }); +} diff --git a/src/lib/db/migrations/0006_big_daredevil.sql b/src/lib/db/migrations/0006_big_daredevil.sql new file mode 100644 index 0000000..2fc86a1 --- /dev/null +++ b/src/lib/db/migrations/0006_big_daredevil.sql @@ -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`); \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0006_snapshot.json b/src/lib/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..db04d80 --- /dev/null +++ b/src/lib/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1028 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a70ac1d2-a318-4822-94a1-a53ada857a0c", + "prevId": "980ae196-7c18-498e-87e9-34feba13f92d", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "attachments_message_id_messages_id_fk": { + "name": "attachments_message_id_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canned_responses": { + "name": "canned_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customers": { + "name": "customers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "portal_token": { + "name": "portal_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "customers_telegram_chat_id_unique": { + "name": "customers_telegram_chat_id_unique", + "columns": [ + "telegram_chat_id" + ], + "isUnique": true + }, + "customers_portal_token_unique": { + "name": "customers_portal_token_unique", + "columns": [ + "portal_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ldap_config": { + "name": "ldap_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 389 + }, + "use_tls": { + "name": "use_tls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bind_dn": { + "name": "bind_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bind_password_enc": { + "name": "bind_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_dn": { + "name": "base_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_filter": { + "name": "user_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'(mail={{email}})'" + }, + "list_filter": { + "name": "list_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'(objectClass=person)'" + }, + "default_role": { + "name": "default_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mailbox_config": { + "name": "mailbox_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 993 + }, + "smtp_host": { + "name": "smtp_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 587 + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow_insecure_tls": { + "name": "allow_insecure_tls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ticket_id": { + "name": "ticket_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_type": { + "name": "author_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_ticket_id_tickets_id_fk": { + "name": "messages_ticket_id_tickets_id_fk", + "tableFrom": "messages", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": [ + "endpoint" + ], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'accent'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "tags_name_unique": { + "name": "tags_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "telegram_config": { + "name": "telegram_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_token_enc": { + "name": "bot_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_username": { + "name": "bot_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ticket_tags": { + "name": "ticket_tags", + "columns": { + "ticket_id": { + "name": "ticket_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ticket_tags_ticket_id_tickets_id_fk": { + "name": "ticket_tags_ticket_id_tickets_id_fk", + "tableFrom": "ticket_tags", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ticket_tags_tag_id_tags_id_fk": { + "name": "ticket_tags_tag_id_tags_id_fk", + "tableFrom": "ticket_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ticket_tags_ticket_id_tag_id_pk": { + "columns": [ + "ticket_id", + "tag_id" + ], + "name": "ticket_tags_ticket_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tickets": { + "name": "tickets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "tickets_customer_id_customers_id_fk": { + "name": "tickets_customer_id_customers_id_fk", + "tableFrom": "tickets", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tickets_assignee_id_users_id_fk": { + "name": "tickets_assignee_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "auth_source": { + "name": "auth_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "widget_sites": { + "name": "widget_sites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_key": { + "name": "site_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_origin": { + "name": "allowed_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "widget_sites_site_key_unique": { + "name": "widget_sites_site_key_unique", + "columns": [ + "site_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 4355c22..a93fcf7 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1785138258070, "tag": "0005_colorful_gorilla_man", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1786130418783, + "tag": "0006_big_daredevil", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 20e205d..88a3f09 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -210,3 +210,21 @@ export const ticketTags = sqliteTable( }, (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, +}); diff --git a/src/lib/push/client.ts b/src/lib/push/client.ts new file mode 100644 index 0000000..534677d --- /dev/null +++ b/src/lib/push/client.ts @@ -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 { + 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 + } +} diff --git a/src/lib/push/service.ts b/src/lib/push/service.ts new file mode 100644 index 0000000..7496ebe --- /dev/null +++ b/src/lib/push/service.ts @@ -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 { + 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 { + await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)); +} + +async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise { + 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 { + 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}`, + }); +} diff --git a/src/lib/push/vapid.ts b/src/lib/push/vapid.ts new file mode 100644 index 0000000..8ac64bd --- /dev/null +++ b/src/lib/push/vapid.ts @@ -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 }; diff --git a/src/lib/tickets/service.ts b/src/lib/tickets/service.ts index 8e5ca01..13c95fc 100644 --- a/src/lib/tickets/service.ts +++ b/src/lib/tickets/service.ts @@ -12,6 +12,7 @@ import { ticketTags, } from "@/lib/db/schema"; import { publishTicketEvent } from "@/lib/events/bus"; +import { notifyPushForCustomerMessage } from "@/lib/push/service"; import type { TicketDTO, MessageDTO, @@ -165,6 +166,14 @@ async function appendMessage(params: { 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 }; }