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
@@ -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}`,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user