feat: PWA push notifications — service worker, VAPID, subscription management

- Add web-push VAPID backend with push_subscriptions table (migration 078)
- Backend push module with sendPushToUser/sendPushToRoom helpers
- Push routes: GET vapid-key, POST subscribe, DELETE unsubscribe
- Trigger push on new chat messages in direct/group rooms
- PWA manifest, service worker, and icons (72–512px)
- Frontend push lib: SW registration, subscribe/unsubscribe helpers
- Push API in api.ts, SW registered in main.tsx
- usePushSubscription hook for auto-subscribe on permission grant
- PushPermissionRow component in ChatWidget settings view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 12:14:03 +03:00
parent 756a71bf2f
commit 1641b14f29
27 changed files with 1056 additions and 7 deletions

View File

@@ -34,6 +34,7 @@ import notificationsRoutes from './routes/notifications'
import scheduleRoutes from './routes/schedule'
import loyaltyRoutes from './routes/loyalty'
import chatRoutes from './routes/chat'
import pushRoutes from './routes/push'
import workstationRoutes from './routes/workstations'
import agentReleaseRoutes from './routes/agent-release'
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
@@ -48,6 +49,7 @@ import publicWidgetRoutes from './routes/publicWidget'
import yookassaWebhookRoutes from './routes/yookassaWebhook'
import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs'
import { initWebPush } from './push'
export async function buildApp() {
const fastify = Fastify({
@@ -131,6 +133,7 @@ export async function buildApp() {
await fastify.register(scheduleRoutes)
await fastify.register(loyaltyRoutes)
await fastify.register(chatRoutes)
await fastify.register(pushRoutes)
await fastify.register(workstationRoutes)
await fastify.register(agentReleaseRoutes)
await fastify.register(wifiSettingsRoutes)
@@ -147,6 +150,7 @@ export async function buildApp() {
await fastify.register(setupAgentWsRoute)
startJobs()
initWebPush()
return fastify
}

67
backend/src/push.ts Normal file
View File

@@ -0,0 +1,67 @@
import webpush from 'web-push'
import { db } from './db'
export function initWebPush() {
webpush.setVapidDetails(
'mailto:noreply@hotelsync.ru',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
)
}
export interface PushPayload {
title: string
body: string
url?: string
tag?: string
icon?: string
}
export async function sendPushToUser(userId: string, payload: PushPayload): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return
try {
const { rows } = await db.query(
'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1',
[userId],
)
for (const sub of rows) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
)
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode
if (statusCode === 410 || statusCode === 404) {
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint])
}
}
}
} catch { /**/ }
}
export async function sendPushToRoom(roomId: string, senderUserId: string, payload: PushPayload): Promise<void> {
if (!process.env.VAPID_PUBLIC_KEY) return
try {
const { rows } = await db.query(
`SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN chat_room_members crm ON crm.user_id = ps.user_id
WHERE crm.room_id = $1 AND ps.user_id != $2`,
[roomId, senderUserId],
)
for (const sub of rows) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload),
)
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode
if (statusCode === 410 || statusCode === 404) {
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint])
}
}
}
} catch { /**/ }
}

View File

@@ -1,5 +1,6 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { sendPushToRoom } from '../push'
type SlugParam = { Params: { slug: string } }
type RoomParam = { Params: { slug: string; roomId: string } }
@@ -183,19 +184,39 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows: roomRows } = await db.query('SELECT type FROM chat_rooms WHERE id = $1', [roomId])
const { rows: roomRows } = await db.query('SELECT type, name FROM chat_rooms WHERE id = $1', [roomId])
if (roomRows[0]?.type === 'notifications')
return reply.code(403).send({ error: 'Cannot post to notifications room' })
const activeRoom = roomRows[0] as { type: string; name: string | null } | undefined
const { text, attachment_url } = request.body
if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' })
const userId = request.user.sub
const { rows } = await db.query(
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null],
[roomId, hotelId, userId, text?.trim() ?? '', attachment_url ?? null],
)
const msg = await fetchMessage(rows[0].id, request.user.sub)
const msg = await fetchMessage(rows[0].id, userId)
// Send push notifications to room members (non-blocking, only for direct/group rooms)
if (activeRoom?.type === 'direct' || activeRoom?.type === 'group') {
void (async () => {
try {
const senderName = (msg as { senderName?: string }).senderName ?? 'Сотрудник'
const roomName = activeRoom?.name ?? (activeRoom?.type === 'group' ? 'Групповой чат' : 'Чат')
void sendPushToRoom(roomId, userId, {
title: roomName ?? senderName,
body: `${senderName.split(' ')[0]}: ${(msg as { text?: string }).text?.slice(0, 100) ?? ''}`,
url: `/${slug}`,
tag: roomId,
icon: '/icons/icon-192.png',
})
} catch { /**/ }
})()
}
return reply.code(201).send(msg)
},
)

View File

@@ -0,0 +1,39 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
const pushRoutes: FastifyPluginAsync = async (fastify) => {
// GET VAPID public key
fastify.get('/api/push/vapid-key', async () => {
return { publicKey: process.env.VAPID_PUBLIC_KEY ?? null }
})
// Subscribe
fastify.post<{ Body: { endpoint: string; p256dh: string; auth: string } }>(
'/api/push/subscribe',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { endpoint, p256dh, auth } = request.body
if (!endpoint || !p256dh || !auth) return reply.code(400).send({ error: 'Missing fields' })
await db.query(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, p256dh = $3, auth = $4`,
[request.user.sub, endpoint, p256dh, auth],
)
return { ok: true }
},
)
// Unsubscribe
fastify.delete<{ Body: { endpoint: string } }>(
'/api/push/unsubscribe',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { endpoint } = request.body
await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2', [endpoint, request.user.sub])
return { ok: true }
},
)
}
export default pushRoutes