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
99 lines
4.2 KiB
TypeScript
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}`,
|
|
});
|
|
}
|