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:
ogrechkoandClaude Sonnet 5 committed 2026-08-07 19:30:27 +00:00
1 parent 6ddbc7e981
commit b9a247c21c
14 files changed
+1457 -2

No files matched your search

+12 -2
View File
@@ -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 (
+44
View File
@@ -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
+7
View File
@@ -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
}
]
}
+18
View File
@@ -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,
});
+52
View File
@@ -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
}
}
+78
View File
@@ -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}`,
});
}
+31
View File
@@ -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 };
+9
View File
@@ -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 };
}