feat: Web Push für Logbuch-Eigner bei Crew-Sync
Benachrichtigt Owner optional per VAPID/Web Push, wenn Collaborators Änderungen synchronisieren — ohne Klartext-Inhalte, mit Opt-in in den Einstellungen, Custom Service Worker und Deep-Link zum Logbuch. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import logbooksRouter from './routes/logbooks.js'
|
||||
import syncRouter from './routes/sync.js'
|
||||
import collaborationRouter from './routes/collaboration.js'
|
||||
import signRouter from './routes/sign.js'
|
||||
import pushRouter from './routes/push.js'
|
||||
import { prisma } from './db.js'
|
||||
|
||||
dotenv.config()
|
||||
@@ -22,6 +23,7 @@ app.use('/api/logbooks', logbooksRouter)
|
||||
app.use('/api/sync', syncRouter)
|
||||
app.use('/api/collaboration', collaborationRouter)
|
||||
app.use('/api/sign', signRouter)
|
||||
app.use('/api/push', pushRouter)
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
function isValidHttpsEndpoint(endpoint: unknown): endpoint is string {
|
||||
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/vapid-public-key', (_req, res) => {
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY
|
||||
if (!publicKey) {
|
||||
return res.status(503).json({ error: 'Push notifications are not configured on this server' })
|
||||
}
|
||||
return res.json({ publicKey })
|
||||
})
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
router.get('/prefs', async (req: any, res) => {
|
||||
try {
|
||||
const prefs = await prisma.userNotificationPrefs.findUnique({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
return res.json({
|
||||
collaboratorChangesEnabled: prefs?.collaboratorChangesEnabled ?? false
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error reading push prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/prefs', async (req: any, res) => {
|
||||
try {
|
||||
const { collaboratorChangesEnabled } = req.body
|
||||
if (typeof collaboratorChangesEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'collaboratorChangesEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
const prefs = await prisma.userNotificationPrefs.upsert({
|
||||
where: { userId: req.userId },
|
||||
create: {
|
||||
userId: req.userId,
|
||||
collaboratorChangesEnabled,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
update: {
|
||||
collaboratorChangesEnabled,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
collaboratorChangesEnabled: prefs.collaboratorChangesEnabled
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating push prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/subscription', async (req: any, res) => {
|
||||
try {
|
||||
const { endpoint, keys, locale, userAgent } = req.body
|
||||
if (!isValidHttpsEndpoint(endpoint)) {
|
||||
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
|
||||
}
|
||||
if (!keys?.p256dh || !keys?.auth || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid subscription keys' })
|
||||
}
|
||||
|
||||
const normalizedLocale =
|
||||
typeof locale === 'string' && (locale === 'de' || locale === 'en') ? locale : null
|
||||
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { endpoint },
|
||||
create: {
|
||||
userId: req.userId,
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
locale: normalizedLocale,
|
||||
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null
|
||||
},
|
||||
update: {
|
||||
userId: req.userId,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
locale: normalizedLocale,
|
||||
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error saving push subscription:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/subscription', async (req: any, res) => {
|
||||
try {
|
||||
const { endpoint } = req.body
|
||||
if (!isValidHttpsEndpoint(endpoint)) {
|
||||
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
|
||||
}
|
||||
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
endpoint,
|
||||
userId: req.userId
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting push subscription:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -24,6 +25,27 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
|
||||
const results = []
|
||||
const ownerNotifications = new Map<
|
||||
string,
|
||||
{ ownerId: string; logbookId: string; count: number }
|
||||
>()
|
||||
|
||||
const recordCollaboratorChange = (
|
||||
ownerId: string,
|
||||
logbookId: string,
|
||||
isOwner: boolean,
|
||||
isCollaborator: unknown,
|
||||
action: string,
|
||||
type: string
|
||||
) => {
|
||||
if (isOwner || !isCollaborator) return
|
||||
if (action !== 'create' && action !== 'update') return
|
||||
if (type === 'logbook') return
|
||||
const key = `${ownerId}:${logbookId}`
|
||||
const entry = ownerNotifications.get(key) ?? { ownerId, logbookId, count: 0 }
|
||||
entry.count += 1
|
||||
ownerNotifications.set(key, entry)
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const { action, type, payloadId, logbookId, data, updatedAt } = item
|
||||
@@ -218,6 +240,14 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
recordCollaboratorChange(
|
||||
logbook.userId,
|
||||
logbookId,
|
||||
isOwner,
|
||||
isCollaborator,
|
||||
action,
|
||||
type
|
||||
)
|
||||
results.push({ payloadId, status: 'success' })
|
||||
} catch (err: any) {
|
||||
console.error(`Error processing sync item ${payloadId}:`, err)
|
||||
@@ -225,6 +255,10 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const { ownerId, logbookId, count } of ownerNotifications.values()) {
|
||||
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
|
||||
}
|
||||
|
||||
return res.json({ results })
|
||||
} catch (error: any) {
|
||||
console.error('Error during sync push:', error)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import webpush from 'web-push'
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
const THROTTLE_MS = 3 * 60 * 1000
|
||||
const lastSentByLogbook = new Map<string, number>()
|
||||
|
||||
let vapidConfigured = false
|
||||
|
||||
function ensureVapid(): boolean {
|
||||
if (vapidConfigured) return true
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY
|
||||
const privateKey = process.env.VAPID_PRIVATE_KEY
|
||||
const subject = process.env.VAPID_SUBJECT
|
||||
if (!publicKey || !privateKey || !subject) {
|
||||
return false
|
||||
}
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey)
|
||||
vapidConfigured = true
|
||||
return true
|
||||
}
|
||||
|
||||
function isThrottled(ownerUserId: string, logbookId: string): boolean {
|
||||
const key = `${ownerUserId}:${logbookId}`
|
||||
const last = lastSentByLogbook.get(key) ?? 0
|
||||
return Date.now() - last < THROTTLE_MS
|
||||
}
|
||||
|
||||
function markSent(ownerUserId: string, logbookId: string): void {
|
||||
lastSentByLogbook.set(`${ownerUserId}:${logbookId}`, Date.now())
|
||||
}
|
||||
|
||||
function notificationCopy(locale: string | null | undefined, changeCount: number): { title: string; body: string } {
|
||||
const isDe = !locale || locale.startsWith('de')
|
||||
const title = 'Kapteins Daagbok'
|
||||
if (isDe) {
|
||||
const body =
|
||||
changeCount > 1
|
||||
? `${changeCount} neue Änderungen in einem Ihrer Logbücher.`
|
||||
: 'Neue Änderung in einem Ihrer Logbücher.'
|
||||
return { title, body }
|
||||
}
|
||||
const body =
|
||||
changeCount > 1
|
||||
? `${changeCount} new changes in one of your logbooks.`
|
||||
: 'New change in one of your logbooks.'
|
||||
return { title, body }
|
||||
}
|
||||
|
||||
export async function notifyOwnerOfCollaboratorChanges(
|
||||
logbookId: string,
|
||||
ownerUserId: string,
|
||||
_actorUserId: string,
|
||||
changeCount: number
|
||||
): Promise<void> {
|
||||
if (!ensureVapid() || changeCount < 1) return
|
||||
if (isThrottled(ownerUserId, logbookId)) return
|
||||
|
||||
const prefs = await prisma.userNotificationPrefs.findUnique({
|
||||
where: { userId: ownerUserId }
|
||||
})
|
||||
if (!prefs?.collaboratorChangesEnabled) return
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: ownerUserId }
|
||||
})
|
||||
if (subscriptions.length === 0) return
|
||||
|
||||
markSent(ownerUserId, logbookId)
|
||||
|
||||
const payloadBase = {
|
||||
tag: `logbook-change-${logbookId}`,
|
||||
renotify: false,
|
||||
data: {
|
||||
url: `/?logbook=${encodeURIComponent(logbookId)}`,
|
||||
logbookId,
|
||||
changeCount
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(async (sub) => {
|
||||
const { title, body } = notificationCopy(sub.locale, changeCount)
|
||||
const payload = JSON.stringify({ title, body, ...payloadBase })
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth }
|
||||
},
|
||||
payload
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const statusCode =
|
||||
err && typeof err === 'object' && 'statusCode' in err
|
||||
? (err as { statusCode: number }).statusCode
|
||||
: undefined
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => {})
|
||||
} else {
|
||||
console.warn('[push] Failed to send notification:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user