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,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,
|
||||
"tag": "0005_colorful_gorilla_man",
|
||||
"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] })],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
Reference in new issue
Block a user