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 { 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 { 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 { 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 { 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 { 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}`, }); }