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:
9
backend/migrations/078_push_subscriptions.sql
Normal file
9
backend/migrations/078_push_subscriptions.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id);
|
||||
101
backend/package-lock.json
generated
101
backend/package-lock.json
generated
@@ -16,11 +16,13 @@
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^8.3.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"fastify": "^4.28.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.12.0"
|
||||
"pg": "^8.12.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -720,7 +722,6 @@
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -748,6 +749,15 @@
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -764,6 +774,15 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
@@ -895,6 +914,12 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
@@ -1328,6 +1353,15 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -1344,6 +1378,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -1428,6 +1475,27 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
|
||||
@@ -1490,6 +1558,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
@@ -2172,9 +2249,27 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^8.3.1",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"fastify": "^4.28.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.12.0"
|
||||
"pg": "^8.12.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
|
||||
@@ -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
67
backend/src/push.ts
Normal 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 { /**/ }
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
39
backend/src/routes/push.ts
Normal file
39
backend/src/routes/push.ts
Normal 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
|
||||
Reference in New Issue
Block a user