mirror of
https://github.com/xtr-dev/payload-notifications.git
synced 2025-12-10 10:53:23 +00:00
Add initial plugin implementation and development setup
This commit is contained in:
254
src/client/push-manager.ts
Normal file
254
src/client/push-manager.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
132
src/client/service-worker.ts
Normal file
132
src/client/service-worker.ts
Normal 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 {}
|
||||
269
src/collections/notifications.ts
Normal file
269
src/collections/notifications.ts
Normal 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
|
||||
}
|
||||
123
src/collections/push-subscriptions.ts
Normal file
123
src/collections/push-subscriptions.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
250
src/endpoints/push-notifications.ts
Normal file
250
src/endpoints/push-notifications.ts
Normal 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
190
src/exports/client.ts
Normal 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
18
src/exports/rsc.ts
Normal 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
65
src/index.ts
Normal 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
125
src/types.ts
Normal 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
43
src/utils/buildFields.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
108
src/utils/richTextExtractor.ts
Normal file
108
src/utils/richTextExtractor.ts
Normal 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
271
src/utils/webPush.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user