Add initial plugin implementation and development setup

This commit is contained in:
2025-09-13 17:06:45 +02:00
parent 45fe248bf3
commit 74cef91401
52 changed files with 16645 additions and 0 deletions

254
src/client/push-manager.ts Normal file
View File

@@ -0,0 +1,254 @@
/**
* Client-side Push Notification Manager
* Handles subscription, permission requests, and communication with the server
*
* @description This module is designed to run in browser environments only
*/
export interface PushSubscriptionData {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
// Check if we're in a browser environment
const isBrowser = typeof window !== 'undefined'
export class ClientPushManager {
private vapidPublicKey: string
private serviceWorkerPath: string
private apiEndpoint: string
constructor(
vapidPublicKey: string,
options: {
serviceWorkerPath?: string
apiEndpoint?: string
} = {}
) {
this.vapidPublicKey = vapidPublicKey
this.serviceWorkerPath = options.serviceWorkerPath || '/sw.js'
this.apiEndpoint = options.apiEndpoint || '/api/push-notifications'
}
/**
* Check if push notifications are supported
*/
public isSupported(): boolean {
if (!isBrowser) return false
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
/**
* Check current notification permission status
*/
public getPermissionStatus(): NotificationPermission {
if (!isBrowser || typeof Notification === 'undefined') return 'default'
return Notification.permission
}
/**
* Request notification permission from user
*/
public async requestPermission(): Promise<NotificationPermission> {
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
const permission = await Notification.requestPermission()
return permission
}
/**
* Register service worker
*/
public async registerServiceWorker(): Promise<ServiceWorkerRegistration> {
if (!isBrowser || !('serviceWorker' in navigator)) {
throw new Error('Service workers are not supported')
}
try {
const registration = await navigator.serviceWorker.register(this.serviceWorkerPath)
console.log('Service worker registered:', registration)
return registration
} catch (error) {
console.error('Service worker registration failed:', error)
throw error
}
}
/**
* Subscribe to push notifications
*/
public async subscribe(): Promise<PushSubscriptionData> {
// Check support
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
// Request permission
const permission = await this.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission not granted')
}
// Register service worker
const registration = await this.registerServiceWorker()
// Wait for service worker to be ready
await navigator.serviceWorker.ready
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
})
const subscriptionData: PushSubscriptionData = {
endpoint: subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
},
}
// Send subscription to server
await this.sendSubscriptionToServer(subscriptionData)
return subscriptionData
}
/**
* Unsubscribe from push notifications
*/
public async unsubscribe(): Promise<void> {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
return
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
return
}
// Unsubscribe from push service
await subscription.unsubscribe()
// Notify server
await this.sendUnsubscribeToServer(subscription.endpoint)
}
/**
* Get current push subscription
*/
public async getSubscription(): Promise<PushSubscriptionData | null> {
if (!isBrowser || !('serviceWorker' in navigator)) return null
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
return null
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
return null
}
return {
endpoint: subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
},
}
}
/**
* Check if user is currently subscribed
*/
public async isSubscribed(): Promise<boolean> {
if (!isBrowser) return false
const subscription = await this.getSubscription()
return subscription !== null
}
/**
* Send subscription data to server
*/
private async sendSubscriptionToServer(subscription: PushSubscriptionData): Promise<void> {
try {
const response = await fetch(`${this.apiEndpoint}/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription,
userAgent: navigator.userAgent,
}),
})
if (!response.ok) {
throw new Error(`Failed to subscribe: ${response.statusText}`)
}
} catch (error) {
console.error('Failed to send subscription to server:', error)
throw error
}
}
/**
* Send unsubscribe request to server
*/
private async sendUnsubscribeToServer(endpoint: string): Promise<void> {
try {
const response = await fetch(`${this.apiEndpoint}/unsubscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint }),
})
if (!response.ok) {
throw new Error(`Failed to unsubscribe: ${response.statusText}`)
}
} catch (error) {
console.error('Failed to send unsubscribe to server:', error)
throw error
}
}
/**
* Convert VAPID public key to Uint8Array
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
/**
* Convert ArrayBuffer to base64 string
*/
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
return window.btoa(binary)
}
}

View File

@@ -0,0 +1,132 @@
/**
* Service Worker for Web Push Notifications
* This file should be served as a static file (e.g., /sw.js)
*/
declare const self: ServiceWorkerGlobalScope
interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
timestamp: number
}
self.addEventListener('install', (event: ExtendableEvent) => {
console.log('[SW] Installing service worker')
self.skipWaiting()
})
self.addEventListener('activate', (event: ExtendableEvent) => {
console.log('[SW] Activating service worker')
event.waitUntil(self.clients.claim())
})
// Handle push events
self.addEventListener('push', (event: PushEvent) => {
console.log('[SW] Push event received')
if (!event.data) {
console.log('[SW] Push event has no data')
return
}
try {
const payload: NotificationPayload = event.data.json()
const { title, body, ...options } = payload
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: options.icon || '/icon-192x192.png',
badge: options.badge || '/badge-72x72.png',
data: options.data,
actions: options.actions,
tag: options.tag,
requireInteraction: options.requireInteraction || false,
timestamp: options.timestamp || Date.now(),
vibrate: [200, 100, 200],
renotify: true,
} as NotificationOptions)
)
} catch (error) {
console.error('[SW] Error processing push notification:', error)
}
})
// Handle notification clicks
self.addEventListener('notificationclick', (event: NotificationEvent) => {
console.log('[SW] Notification click received')
event.notification.close()
const data = event.notification.data || {}
// Handle action button clicks
if (event.action) {
console.log('[SW] Action clicked:', event.action)
// Custom action handling based on action type
switch (event.action) {
case 'view':
if (data.url) {
event.waitUntil(
self.clients.openWindow(data.url)
)
}
break
case 'dismiss':
// Just close the notification
break
default:
console.log('[SW] Unknown action:', event.action)
}
} else {
// Default click behavior - open the app
const urlToOpen = data.url || '/'
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((windowClients: readonly WindowClient[]) => {
// Check if there is already an open window
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus()
}
}
// If no window is open, open a new one
if (self.clients.openWindow) {
return self.clients.openWindow(urlToOpen)
}
})
)
}
})
// Handle notification close events
self.addEventListener('notificationclose', (event: NotificationEvent) => {
console.log('[SW] Notification closed:', event.notification.tag)
// Optional: Send analytics or tracking data
const data = event.notification.data || {}
if (data.trackClose) {
// Send tracking data to your analytics service
fetch('/api/notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'close',
notificationId: data.id,
timestamp: Date.now(),
}),
}).catch(console.error)
}
})
export {}

View File

@@ -0,0 +1,269 @@
import type { CollectionConfig, Field } from 'payload'
import type { NotificationsPluginOptions, NotificationAccess } from '../types'
import { buildRelationshipFields } from '../utils/buildFields'
import { WebPushManager } from '../utils/webPush'
import { defaultNotificationTransformer } from '../utils/richTextExtractor'
/**
* Creates the notifications collection configuration
* Includes core fields plus dynamically generated relationship fields
*/
export function createNotificationsCollection(options: NotificationsPluginOptions = {}): CollectionConfig {
const {
collections = {},
relationships = [],
access = {},
fields: customFields = [],
} = options
const slug = collections.slug || 'notifications'
const labels = {
singular: collections.labels?.singular || 'Notification',
plural: collections.labels?.plural || 'Notifications',
}
// Default access control - authenticated users can read, admins can manage
const defaultAccess: NotificationAccess = {
read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'),
}
// Build channel field if channels are configured
const channelField: Field[] = options.channels && options.channels.length > 0 ? [
{
name: 'channel',
type: 'select',
label: 'Channel',
options: options.channels.map(channel => ({
label: channel.name,
value: channel.id,
})),
required: false,
admin: {
description: 'The notification channel - only subscribers to this channel will receive the notification',
position: 'sidebar',
},
},
] : []
// Default recipient field (relationship to users)
// Users can add custom recipient fields via the fields option and use findSubscriptions hook
const recipientField: Field = {
name: 'recipient',
type: 'relationship',
relationTo: 'users',
label: 'Recipient',
required: false,
admin: {
description: 'The user who should receive this notification (optional if using custom recipient fields)',
},
}
// Build core fields
const coreFields: Field[] = [
{
name: 'title',
type: 'text',
label: 'Title',
required: true,
admin: {
description: 'The notification title that will be displayed to users',
},
},
{
name: 'message',
type: 'richText',
label: 'Message',
required: true,
admin: {
description: 'The notification message content',
},
},
recipientField,
...channelField,
{
name: 'isRead',
type: 'checkbox',
label: 'Read',
defaultValue: false,
admin: {
description: 'Whether this notification has been read by the recipient',
position: 'sidebar',
},
},
{
name: 'readAt',
type: 'date',
label: 'Read At',
admin: {
description: 'When this notification was marked as read',
position: 'sidebar',
condition: (_: any, siblingData: any) => siblingData?.isRead,
},
},
]
// Build relationship fields
const relationshipFields = buildRelationshipFields(relationships)
// Combine all fields
const allFields = [...coreFields, ...relationshipFields, ...customFields]
const config: CollectionConfig = {
slug,
labels,
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'recipient', 'isRead', 'createdAt'],
description: 'Manage user notifications and messaging',
},
fields: allFields,
access: {
read: access.read || defaultAccess.read!,
create: access.create || defaultAccess.create!,
update: access.update || defaultAccess.update!,
delete: access.delete || defaultAccess.delete!,
},
timestamps: true,
}
// Add hooks for automatic push notifications if web push is enabled
if (options.webPush?.enabled && options.webPush.autoPush) {
config.hooks = {
afterChange: [
async ({ doc, operation, req }) => {
// Only send push notifications for new notifications
if (operation !== 'create') return
try {
const webPushConfig = options.webPush!
const pushManager = new WebPushManager(webPushConfig, req.payload)
// Transform notification content using custom transformer or default
const transformer = webPushConfig.transformNotification || defaultNotificationTransformer
const pushContent = transformer(doc)
console.log('[Notifications Plugin] Sending push notification for notification:', doc.id)
console.log('[Notifications Plugin] Push content:', pushContent)
let results: Array<{ success: boolean; error?: any }> = []
// Check if custom findSubscriptions hook is provided
if (webPushConfig.findSubscriptions) {
// Use custom hook to find subscriptions
console.log('[Notifications Plugin] Using custom findSubscriptions hook')
const subscriptions = await webPushConfig.findSubscriptions(doc, req.payload)
if (!subscriptions || subscriptions.length === 0) {
console.log('[Notifications Plugin] No subscriptions found via custom hook')
return
}
// Send notifications directly to the found subscriptions
const notificationPayload = JSON.stringify({
title: pushContent.title,
body: pushContent.body,
icon: pushContent.icon || '/icon-192x192.png',
badge: pushContent.badge || '/badge-72x72.png',
image: pushContent.image,
data: pushContent.data,
actions: pushContent.actions,
tag: pushContent.tag,
requireInteraction: pushContent.requireInteraction || false,
timestamp: Date.now(),
})
results = await Promise.allSettled(
subscriptions.map(async (sub: any) => {
try {
const pushSub = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
}
await pushManager.sendNotification(pushSub, notificationPayload)
return { success: true }
} catch (error: any) {
// Handle expired/invalid subscriptions
if (error.statusCode === 410 || error.statusCode === 404) {
await req.payload.update({
collection: 'push-subscriptions',
id: sub.id,
data: { isActive: false },
})
}
return { success: false, error }
}
})
).then(results =>
results.map((result) =>
result.status === 'fulfilled'
? result.value
: { success: false, error: result.reason }
)
)
} else {
// Use default behavior - send to recipient user (if recipient is provided)
if (!doc.recipient) {
console.warn('[Notifications Plugin] No recipient found and no findSubscriptions hook provided - skipping push notification')
return
}
let recipientId: string
if (typeof doc.recipient === 'string') {
recipientId = doc.recipient
} else if (doc.recipient?.id) {
recipientId = doc.recipient.id
} else {
console.warn('[Notifications Plugin] No valid recipient found for push notification')
return
}
console.log('[Notifications Plugin] Using default user-based recipient logic for:', recipientId)
// Send push notification to the recipient user
results = await pushManager.sendToRecipient(
recipientId,
pushContent.title,
pushContent.body,
{
icon: pushContent.icon,
badge: pushContent.badge,
image: pushContent.image,
data: pushContent.data,
actions: pushContent.actions,
tag: pushContent.tag,
requireInteraction: pushContent.requireInteraction,
channel: doc.channel,
recipientType: 'user',
}
)
}
const successful = results.filter(r => r.success).length
const failed = results.filter(r => !r.success).length
console.log(`[Notifications Plugin] Push notification results: ${successful} sent, ${failed} failed`)
if (failed > 0) {
console.warn('[Notifications Plugin] Some push notifications failed:',
results.filter(r => !r.success).map(r => r.error)
)
}
} catch (error) {
console.error('[Notifications Plugin] Error sending push notification:', error)
// Don't throw error - we don't want to prevent notification creation
}
},
],
}
}
return config
}

View File

@@ -0,0 +1,123 @@
import type { CollectionConfig } from 'payload'
import type { NotificationAccess, NotificationsPluginOptions } from '../types'
/**
* Creates a collection to store web push subscriptions
* Each user can have multiple subscriptions (different devices/browsers)
*/
export function createPushSubscriptionsCollection(access: NotificationAccess = {}, options: NotificationsPluginOptions = {}): CollectionConfig {
const defaultAccess: NotificationAccess = {
read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user),
}
return {
slug: 'push-subscriptions',
labels: {
singular: 'Push Subscription',
plural: 'Push Subscriptions',
},
admin: {
useAsTitle: 'endpoint',
defaultColumns: ['user', 'endpoint', 'createdAt'],
description: 'Web push notification subscriptions for users',
// hidden: true, // Hide from main navigation
},
fields: [
{
name: 'user',
type: 'relationship',
relationTo: 'users',
label: 'User',
required: true,
admin: {
description: 'The user this push subscription belongs to',
},
},
{
name: 'endpoint',
type: 'text',
label: 'Endpoint',
required: true,
unique: true,
admin: {
description: 'Push service endpoint URL',
},
},
{
name: 'p256dh',
type: 'text',
label: 'P256DH Key',
required: true,
admin: {
description: 'User agent public key for encryption',
},
},
{
name: 'auth',
type: 'text',
label: 'Auth Secret',
required: true,
admin: {
description: 'User agent authentication secret',
},
},
{
name: 'userAgent',
type: 'text',
label: 'User Agent',
admin: {
description: 'Browser/device information',
},
},
{
name: 'channels',
type: 'select',
label: 'Subscribed Channels',
options: options.channels && options.channels.length > 0
? options.channels.map(channel => ({
label: channel.name,
value: channel.id,
}))
: [{ label: 'All Notifications', value: 'all' }],
hasMany: true,
defaultValue: options.channels && options.channels.length > 0
? options.channels.filter(channel => channel.defaultEnabled !== false).map(channel => channel.id)
: ['all'],
admin: {
description: 'Channels this subscription is subscribed to - leave empty for all notifications',
},
},
{
name: 'isActive',
type: 'checkbox',
label: 'Active',
defaultValue: true,
admin: {
description: 'Whether this subscription is still active',
position: 'sidebar',
},
},
],
access: {
read: access.read || defaultAccess.read!,
create: access.create || defaultAccess.create!,
update: access.update || defaultAccess.update!,
delete: access.delete || defaultAccess.delete!,
},
timestamps: true,
hooks: {
beforeChange: [
({ req, data }: { req: any; data: any }) => {
// For user-based subscriptions, default to current user
if (req.user && !data.user) {
data.user = req.user.id
}
return data
},
],
},
}
}

View File

@@ -0,0 +1,250 @@
import type { Endpoint, PayloadRequest } from 'payload'
import { WebPushManager } from '../utils/webPush'
import type { NotificationsPluginOptions } from '../types'
/**
* Create push notification API endpoints
*/
export function createPushNotificationEndpoints(options: NotificationsPluginOptions): Endpoint[] {
if (!options.webPush?.enabled) {
return []
}
const webPushConfig = options.webPush
return [
// Subscribe endpoint
{
path: '/push-notifications/subscribe',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
if (!req.user) {
return Response.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { subscription, userAgent, channels } = body
if (!subscription || !subscription.endpoint) {
return Response.json({ error: 'Invalid subscription data' }, { status: 400 })
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
await pushManager.subscribe(
String(req.user.id),
subscription,
userAgent,
channels
)
return Response.json({ success: true })
} catch (error: any) {
console.error('Push subscription error:', error)
return Response.json(
{ error: 'Failed to subscribe to push notifications' },
{ status: 500 }
)
}
},
},
// Unsubscribe endpoint
{
path: '/push-notifications/unsubscribe',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { endpoint } = body
if (!endpoint) {
return Response.json({ error: 'Endpoint is required' }, { status: 400 })
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
await pushManager.unsubscribe(endpoint)
return Response.json({ success: true })
} catch (error: any) {
console.error('Push unsubscribe error:', error)
return Response.json(
{ error: 'Failed to unsubscribe from push notifications' },
{ status: 500 }
)
}
},
},
// Get VAPID public key
{
path: '/push-notifications/vapid-public-key',
method: 'get',
handler: async (req: PayloadRequest) => {
try {
return Response.json({
publicKey: webPushConfig.vapidPublicKey,
})
} catch (error: any) {
console.error('VAPID key error:', error)
return Response.json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
)
}
},
},
// Send test notification (admin only)
{
path: '/push-notifications/test',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
if (!req.user || req.user.role !== 'admin') {
return Response.json({ error: 'Admin access required' }, { status: 403 })
}
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, title, body: messageBody, options: notificationOptions } = body
if (!userId || !title || !messageBody) {
return Response.json(
{ error: 'userId, title, and body are required' },
{ status: 400 }
)
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
const results = await pushManager.sendToUser(
userId,
title,
messageBody,
notificationOptions
)
return Response.json({
success: true,
results,
sent: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
})
} catch (error: any) {
console.error('Test notification error:', error)
return Response.json(
{ error: 'Failed to send test notification' },
{ status: 500 }
)
}
},
},
// Send notification to user (authenticated users can send to themselves, admins to anyone)
{
path: '/push-notifications/send',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
if (!req.user) {
return Response.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, title, body: messageBody, options: notificationOptions } = body
if (!userId || !title || !messageBody) {
return Response.json(
{ error: 'userId, title, and body are required' },
{ status: 400 }
)
}
// Users can only send notifications to themselves, admins can send to anyone
if (userId !== req.user.id && req.user.role !== 'admin') {
return Response.json(
{ error: 'You can only send notifications to yourself' },
{ status: 403 }
)
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
const results = await pushManager.sendToUser(
userId,
title,
messageBody,
notificationOptions
)
return Response.json({
success: true,
results,
sent: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
})
} catch (error: any) {
console.error('Send notification error:', error)
return Response.json(
{ error: 'Failed to send notification' },
{ status: 500 }
)
}
},
},
// Tracking endpoint for analytics
{
path: '/push-notifications/track',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { action, notificationId, timestamp } = body
// Log the tracking event (you can extend this to save to database)
console.log('Push notification tracking:', {
action,
notificationId,
timestamp,
userAgent: req.headers.get('user-agent'),
// Note: req.ip may not be available in all environments
})
// You could save tracking data to a collection here
// await req.payload.create({
// collection: 'notification-analytics',
// data: { action, notificationId, timestamp, ... }
// })
return Response.json({ success: true })
} catch (error: any) {
console.error('Tracking error:', error)
return Response.json(
{ error: 'Failed to track notification event' },
{ status: 500 }
)
}
},
},
]
}

190
src/exports/client.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* Client-side exports for the notifications plugin
* Import from '@xtr-dev/payload-notifications/client'
*/
export { ClientPushManager } from '../client/push-manager'
export type { PushSubscriptionData } from '../client/push-manager'
// Service worker utilities
export const serviceWorkerCode = `
/**
* Service Worker for Web Push Notifications
* This code should be served as /sw.js or similar
*/
declare const self: ServiceWorkerGlobalScope
interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
timestamp: number
}
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker')
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker')
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
if (!event.data) return
try {
const payload: NotificationPayload = event.data.json()
const { title, body, ...options } = payload
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: options.icon || '/icon-192x192.png',
badge: options.badge || '/badge-72x72.png',
image: options.image,
data: options.data,
actions: options.actions,
tag: options.tag,
requireInteraction: options.requireInteraction || false,
timestamp: options.timestamp || Date.now(),
vibrate: [200, 100, 200],
renotify: true,
})
)
} catch (error) {
console.error('[SW] Error processing push notification:', error)
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const data = event.notification.data || {}
if (event.action) {
switch (event.action) {
case 'view':
if (data.url) {
event.waitUntil(self.clients.openWindow(data.url))
}
break
case 'dismiss':
break
}
} else {
const urlToOpen = data.url || '/'
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((windowClients) => {
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus()
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(urlToOpen)
}
})
)
}
})
self.addEventListener('notificationclose', (event) => {
const data = event.notification.data || {}
if (data.trackClose) {
fetch('/api/push-notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'close',
notificationId: data.id,
timestamp: Date.now(),
}),
}).catch(console.error)
}
})
export {}
`
// React types (conditional)
interface ReactHooks {
useState: any
useEffect: any
}
// Try to import React hooks
let ReactHooks: ReactHooks | null = null
try {
const React = require('react')
ReactHooks = {
useState: React.useState,
useEffect: React.useEffect
}
} catch {
// React not available
}
/**
* React hook for managing push notifications
* Only works if React is available in the environment
*/
export function usePushNotifications(vapidPublicKey: string) {
if (!ReactHooks) {
throw new Error('React is not available. Make sure React is installed to use this hook.')
}
const [isSupported, setIsSupported] = ReactHooks.useState(false)
const [isSubscribed, setIsSubscribed] = ReactHooks.useState(false)
const [permission, setPermission] = ReactHooks.useState('default' as NotificationPermission)
const [pushManager, setPushManager] = ReactHooks.useState(null)
ReactHooks.useEffect(() => {
const { ClientPushManager } = require('../client/push-manager')
const manager = new ClientPushManager(vapidPublicKey)
setPushManager(manager)
setIsSupported(manager.isSupported())
setPermission(manager.getPermissionStatus())
if (manager.isSupported()) {
manager.isSubscribed().then(setIsSubscribed)
}
}, [vapidPublicKey])
const subscribe = async () => {
if (!pushManager) throw new Error('Push manager not initialized')
await pushManager.subscribe()
setIsSubscribed(true)
setPermission('granted')
}
const unsubscribe = async () => {
if (!pushManager) throw new Error('Push manager not initialized')
await pushManager.unsubscribe()
setIsSubscribed(false)
}
const requestPermission = async () => {
if (!pushManager) throw new Error('Push manager not initialized')
const newPermission = await pushManager.requestPermission()
setPermission(newPermission)
return newPermission
}
return {
isSupported,
isSubscribed,
permission,
subscribe,
unsubscribe,
requestPermission,
pushManager,
}
}

18
src/exports/rsc.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* React Server Component exports for the notifications plugin
* Import from '@xtr-dev/payload-notifications/rsc'
*/
export { WebPushManager } from '../utils/webPush'
export { createPushNotificationEndpoints } from '../endpoints/push-notifications'
export { createPushSubscriptionsCollection } from '../collections/push-subscriptions'
// Re-export types that are useful on the server side
export type {
WebPushConfig,
PushSubscription,
NotificationsPluginOptions,
NotificationRelationship,
NotificationCollectionConfig,
NotificationAccess,
} from '../types'

65
src/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { Config } from 'payload'
import type { NotificationsPluginOptions, NotificationsPlugin } from './types'
import { createNotificationsCollection } from './collections/notifications'
import { createPushSubscriptionsCollection } from './collections/push-subscriptions'
import { createPushNotificationEndpoints } from './endpoints/push-notifications'
/**
* PayloadCMS Notifications Plugin
*
* Adds a configurable notifications collection with support for:
* - Title and rich text message content
* - Recipient targeting
* - Read/unread status tracking
* - Configurable relationship attachments to any collection
*
* @param options Plugin configuration options
* @returns Configured PayloadCMS plugin
*/
export const notificationsPlugin: NotificationsPlugin = (options = {}) => {
return (config: Config): Config => {
// Create the notifications collection with provided options
const notificationsCollection = createNotificationsCollection(options)
// Add collections to the Payload config
const collections = config.collections || []
const newCollections = [
...collections,
notificationsCollection,
]
// Add push subscriptions collection if web push is enabled
if (options.webPush?.enabled) {
const pushSubscriptionsCollection = createPushSubscriptionsCollection(options.access, options)
newCollections.push(pushSubscriptionsCollection)
}
// Create push notification endpoints if web push is enabled
const endpoints = config.endpoints || []
const pushEndpoints = options.webPush?.enabled
? createPushNotificationEndpoints(options)
: []
return {
...config,
collections: newCollections,
endpoints: [
...endpoints,
...pushEndpoints,
],
}
}
}
// Export types for consumers
export type {
NotificationsPluginOptions,
NotificationRelationship,
NotificationCollectionConfig,
NotificationAccess,
NotificationChannel,
WebPushConfig,
} from './types'
// Default export
export default notificationsPlugin

125
src/types.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { Config, CollectionConfig, Access, Field } from 'payload'
import type * as webpush from 'web-push'
/**
* Configuration for a relationship field in the notifications collection
*/
export interface NotificationRelationship {
/** Field name in the attachments group */
name: string
/** Target collection slug to relate to */
relationTo: string
/** Label displayed in admin UI */
label?: string
/** Whether this relationship is required */
required?: boolean
/** Allow multiple selections */
hasMany?: boolean
}
/**
* Collection configuration options
*/
export interface NotificationCollectionConfig {
/** Collection slug */
slug?: string
/** Collection labels for admin UI */
labels?: {
singular?: string
plural?: string
}
}
/**
* Access control configuration for notifications collection
*/
export interface NotificationAccess {
read?: Access
create?: Access
update?: Access
delete?: Access
}
/**
* Web push subscription data structure
*/
export interface PushSubscription {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
/**
* Notification channel configuration
*/
export interface NotificationChannel {
/** Unique channel identifier */
id: string
/** Display name for the channel */
name: string
/** Channel description */
description?: string
/** Default enabled state for new subscriptions */
defaultEnabled?: boolean
}
/**
* Web push configuration options
*/
export interface WebPushConfig {
/** VAPID public key for push notifications */
vapidPublicKey: string
/** VAPID private key for push notifications */
vapidPrivateKey: string
/** Contact email for VAPID */
vapidSubject: string
/** Enable web push notifications */
enabled?: boolean
/** Custom push notification options */
options?: webpush.RequestOptions
/** Automatically send push notifications when notifications are created */
autoPush?: boolean
/** Custom notification content transformer */
transformNotification?: (notification: any) => {
title: string
body: string
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
}
/**
* Custom hook to find push subscriptions for a notification
* This allows implementing anonymous notifications or custom recipient logic
* If not provided, defaults to user-based subscriptions
*/
findSubscriptions?: (notification: any, payload: any) => Promise<any[]>
}
/**
* Main plugin configuration options
*/
export interface NotificationsPluginOptions {
/** Collection configuration */
collections?: NotificationCollectionConfig
/** Array of configurable relationship fields */
relationships?: NotificationRelationship[]
/** Custom access control functions */
access?: NotificationAccess
/** Additional custom fields to add to the collection */
fields?: Field[]
/** Web push notification configuration */
webPush?: WebPushConfig
/** Notification channels configuration */
channels?: NotificationChannel[]
}
/**
* Plugin function type
*/
export type NotificationsPlugin = (options?: NotificationsPluginOptions) => (config: Config) => Config

43
src/utils/buildFields.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { Field } from 'payload'
import type { NotificationRelationship } from '../types'
/**
* Builds relationship fields dynamically based on plugin configuration
* Creates individual relationship fields within an attachments group
*/
export function buildRelationshipFields(relationships: NotificationRelationship[]): Field[] {
if (!relationships || relationships.length === 0) {
return []
}
// Create individual relationship fields
const relationshipFields: Field[] = relationships.map((rel) => {
const baseField = {
name: rel.name,
type: 'relationship' as const,
relationTo: rel.relationTo,
label: rel.label || `Related ${rel.relationTo}`,
required: rel.required || false,
}
// Add hasMany conditionally to satisfy the type constraints
if (rel.hasMany) {
return {
...baseField,
hasMany: true,
}
}
return baseField
})
// Wrap relationship fields in a group called "attachments"
return [
{
name: 'attachments',
type: 'group',
label: 'Attachments',
fields: relationshipFields,
},
]
}

View File

@@ -0,0 +1,108 @@
/**
* Utility functions for extracting plain text from Payload rich text fields
*/
/**
* Extract plain text from Payload rich text content
* Supports both Lexical and Slate formats
*/
export function extractTextFromRichText(richText: any): string {
if (!richText) return ''
if (typeof richText === 'string') {
return richText
}
if (Array.isArray(richText)) {
return richText
.map(block => extractTextFromBlock(block))
.filter(Boolean)
.join(' ')
}
return ''
}
/**
* Extract text from a rich text block
*/
function extractTextFromBlock(block: any): string {
if (!block) return ''
// Handle Lexical format (Payload 3.x default)
if (block.type === 'paragraph' || block.type === 'heading') {
return extractTextFromChildren(block.children || [])
}
// Handle direct children array
if (block.children && Array.isArray(block.children)) {
return extractTextFromChildren(block.children)
}
// Handle text nodes directly
if (typeof block.text === 'string') {
return block.text
}
// Handle Slate format (legacy)
if (block.type === 'p' || block.type === 'h1' || block.type === 'h2') {
return extractTextFromChildren(block.children || [])
}
return ''
}
/**
* Extract text from children array
*/
function extractTextFromChildren(children: any[]): string {
if (!Array.isArray(children)) return ''
return children
.map(child => {
if (typeof child === 'string') return child
if (typeof child.text === 'string') return child.text
if (child.children) return extractTextFromChildren(child.children)
return ''
})
.join('')
}
/**
* Truncate text to a maximum length with ellipsis
*/
export function truncateText(text: string, maxLength: number = 100): string {
if (!text || text.length <= maxLength) return text
return text.substring(0, maxLength - 3) + '...'
}
/**
* Default notification content transformer
* Converts a notification document to push notification format
*/
export function defaultNotificationTransformer(notification: any) {
const title = notification.title || 'New Notification'
// Extract plain text from rich text message
const messageText = extractTextFromRichText(notification.message)
const body = truncateText(messageText, 120) || 'You have a new notification'
return {
title,
body,
icon: '/icons/notification-icon.png',
badge: '/icons/notification-badge.png',
image: undefined, // Optional image property
data: {
notificationId: notification.id,
url: `/admin/collections/notifications/${notification.id}`,
createdAt: notification.createdAt,
},
actions: [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
],
tag: `notification-${notification.id}`,
requireInteraction: false,
}
}

271
src/utils/webPush.ts Normal file
View File

@@ -0,0 +1,271 @@
import webpush from 'web-push'
import type { Payload } from 'payload'
import type { WebPushConfig, PushSubscription } from '../types'
/**
* Web Push utility class for handling push notifications
*/
export class WebPushManager {
private config: WebPushConfig
private payload: Payload
private initialized = false
constructor(config: WebPushConfig, payload: Payload) {
this.config = config
this.payload = payload
}
/**
* Initialize web-push with VAPID details
*/
public init(): void {
if (this.initialized) return
webpush.setVapidDetails(
this.config.vapidSubject,
this.config.vapidPublicKey,
this.config.vapidPrivateKey
)
this.initialized = true
}
/**
* Send push notification to a specific subscription
*/
public async sendNotification(
subscription: PushSubscription,
payload: string | Buffer,
options?: webpush.RequestOptions
): Promise<webpush.SendResult> {
this.init()
const pushSubscription = {
endpoint: subscription.endpoint,
keys: subscription.keys,
}
const requestOptions = {
...this.config.options,
...options,
}
return webpush.sendNotification(pushSubscription, payload, requestOptions)
}
/**
* Send push notification to all active subscriptions for a recipient
*/
public async sendToRecipient(
recipientId: string,
title: string,
body: string,
options?: {
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
channel?: string
recipientType?: 'user' | 'text' | 'email'
}
): Promise<Array<{ success: boolean; error?: any }>> {
// Build query conditions for filtering subscriptions based on recipient type
const whereConditions: any[] = [
{ isActive: { equals: true } },
]
// Add recipient filtering based on type
if (options?.recipientType === 'text' || options?.recipientType === 'email') {
// For text/email recipients, look for recipient field
whereConditions.push({ recipient: { equals: recipientId } })
} else {
// Default to user relationship
whereConditions.push({ user: { equals: recipientId } })
}
// Add channel filtering if specified
if (options?.channel) {
whereConditions.push({
or: [
{ channels: { contains: options.channel } },
{ channels: { contains: 'all' } },
{ channels: { exists: false } }, // Handle subscriptions without channels field (backwards compatibility)
],
})
}
// Get all active push subscriptions for the user filtered by channel
const subscriptions = await this.payload.find({
collection: 'push-subscriptions',
where: {
and: whereConditions,
},
})
if (subscriptions.docs.length === 0) {
return []
}
const notificationPayload = JSON.stringify({
title,
body,
icon: options?.icon || '/icon-192x192.png',
badge: options?.badge || '/badge-72x72.png',
image: options?.image,
data: options?.data,
actions: options?.actions,
tag: options?.tag,
requireInteraction: options?.requireInteraction || false,
timestamp: Date.now(),
})
const results = await Promise.allSettled(
subscriptions.docs.map(async (sub: any) => {
try {
const pushSub: PushSubscription = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
}
await this.sendNotification(pushSub, notificationPayload)
return { success: true }
} catch (error: any) {
// Handle expired/invalid subscriptions
if (error.statusCode === 410 || error.statusCode === 404) {
// Mark subscription as inactive
await this.payload.update({
collection: 'push-subscriptions',
id: sub.id,
data: { isActive: false },
})
}
return { success: false, error }
}
})
)
return results.map((result) =>
result.status === 'fulfilled'
? result.value
: { success: false, error: result.reason }
)
}
/**
* Send push notification to all active subscriptions for a user (backward compatibility)
* @deprecated Use sendToRecipient instead
*/
public async sendToUser(
userId: string,
title: string,
body: string,
options?: {
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
channel?: string
}
): Promise<Array<{ success: boolean; error?: any }>> {
return this.sendToRecipient(userId, title, body, {
...options,
recipientType: 'user'
})
}
/**
* Subscribe a user to push notifications
*/
public async subscribe(
userId: string,
subscription: PushSubscription,
userAgent?: string,
channels?: string[]
): Promise<void> {
try {
// Check if subscription already exists
const existing = await this.payload.find({
collection: 'push-subscriptions',
where: {
endpoint: { equals: subscription.endpoint },
},
limit: 1,
})
if (existing.docs.length > 0) {
// Update existing subscription
await this.payload.update({
collection: 'push-subscriptions',
id: existing.docs[0].id,
data: {
user: userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
channels: channels || ['all'],
isActive: true,
},
})
} else {
// Create new subscription
await this.payload.create({
collection: 'push-subscriptions',
data: {
user: userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
channels: channels || ['all'],
isActive: true,
},
})
}
} catch (error) {
console.error('Failed to save push subscription:', error)
throw error
}
}
/**
* Unsubscribe a user from push notifications
*/
public async unsubscribe(endpoint: string): Promise<void> {
try {
const subscription = await this.payload.find({
collection: 'push-subscriptions',
where: {
endpoint: { equals: endpoint },
},
limit: 1,
})
if (subscription.docs.length > 0) {
await this.payload.update({
collection: 'push-subscriptions',
id: subscription.docs[0].id,
data: { isActive: false },
})
}
} catch (error) {
console.error('Failed to unsubscribe:', error)
throw error
}
}
/**
* Get VAPID public key for client-side subscription
*/
public getVapidPublicKey(): string {
return this.config.vapidPublicKey
}
}