Files
top-tickets/src/lib/push/service.ts
T
ogrechkoandClaude Sonnet 5 f5e49b5c53 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
2026-09-07 06:49:34 +00:00

99 lines
4.2 KiB
TypeScript

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";
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,
userAgent?: string,
): Promise<void> {
await db
.insert(pushSubscriptions)
.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, userAgent },
});
}
export async function deletePushSubscriptionByEndpoint(endpoint: string): Promise<void> {
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;
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}`,
});
}