Let users see and unsubscribe their own push devices

Fixes duplicate/triple OS notifications on machines where more than
one browser (e.g. both Chrome and Edge on Windows) independently got
notification permission for the site — each held its own valid
subscription, so every event pushed to both.

Adds a userAgent column (captured client-side at subscribe time,
display-only) and a Settings > Account section listing a user's own
subscribed browsers with a friendly label plus an unsubscribe button,
backed by new GET/DELETE /api/push/subscriptions endpoints scoped to
the caller's own rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-09-07 06:49:34 +00:00
1 parent 4d393aec91
commit f5e49b5c53
11 files changed
+1374 -6

No files matched your search

+24 -4
View File
@@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm";
import { eq, inArray, and } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { pushSubscriptions, users } from "@/lib/db/schema";
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
@@ -10,13 +10,17 @@ export interface PushSubscriptionKeys {
}
/** 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> {
export async function savePushSubscription(
userId: string,
sub: PushSubscriptionKeys,
userAgent?: string,
): Promise<void> {
await db
.insert(pushSubscriptions)
.values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth })
.values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent })
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth },
set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent },
});
}
@@ -24,6 +28,22 @@ export async function deletePushSubscriptionByEndpoint(endpoint: string): Promis
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
}
/** A user's own subscribed devices/browsers, for the "manage notifications" settings list — never exposes the endpoint/keys, only what's useful to tell rows apart. */
export async function listPushSubscriptionsForUser(userId: string) {
const rows = await db.query.pushSubscriptions.findMany({
where: eq(pushSubscriptions.userId, userId),
orderBy: (t, { desc }) => desc(t.createdAt),
});
return rows.map((r) => ({ id: r.id, userAgent: r.userAgent, createdAt: r.createdAt }));
}
/** Deletes one of the current user's own subscriptions by id — scoped so a user can only ever remove their own. */
export async function deleteOwnPushSubscription(userId: string, id: string): Promise<void> {
await db
.delete(pushSubscriptions)
.where(and(eq(pushSubscriptions.id, id), eq(pushSubscriptions.userId, userId)));
}
async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise<void> {
if (userIds.length === 0 || !ensureVapidConfigured()) return;