Refactor: Simplify notifications plugin

This commit is contained in:
2025-09-28 16:26:43 +02:00
parent 9f78d3ef72
commit c0e2177d71
9 changed files with 128 additions and 208 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ yarn-error.log*
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/dev.db

View File

@@ -75,27 +75,8 @@ export default function DemoPage() {
setLoading(true) setLoading(true)
try { try {
await pushManager.unsubscribe() await pushManager.unsubscribe()
setIsSubscribed(false)
// Remove the subscription from Payload's database alert('Successfully unsubscribed from push notifications')
const response = await fetch('/api/push-notifications/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user: 'customer@example.com', // Associate with the demo customer user
}),
})
if (response.ok) {
setIsSubscribed(false)
alert('Successfully unsubscribed from push notifications')
} else {
const error = await response.text()
console.warn('Failed to remove subscription from database:', error)
setIsSubscribed(false)
alert('Unsubscribed from browser, but may still be in database')
}
} catch (error) { } catch (error) {
console.error('Failed to unsubscribe:', error) console.error('Failed to unsubscribe:', error)
alert('Failed to unsubscribe from push notifications: ' + (error as Error).message) alert('Failed to unsubscribe from push notifications: ' + (error as Error).message)

View File

@@ -1,38 +0,0 @@
import type { EmailAdapter, SendEmailOptions } from 'payload'
/**
* Logs all emails to stdout
*/
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
name: 'test-email-adapter',
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload Test',
sendEmail: async (message) => {
const stringifiedTo = getStringifiedToAddress(message)
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
payload.logger.info({ content: message, msg: res })
return Promise.resolve()
},
})
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
let stringifiedTo: string | undefined
if (typeof message.to === 'string') {
stringifiedTo = message.to
} else if (Array.isArray(message.to)) {
stringifiedTo = message.to
.map((to: { address: string } | string) => {
if (typeof to === 'string') {
return to
} else if (to.address) {
return to.address
}
return ''
})
.join(', ')
} else if (message.to?.address) {
stringifiedTo = message.to.address
}
return stringifiedTo
}

View File

@@ -1,13 +1,13 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path' import path from 'path'
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
import sharp from 'sharp' import sharp from 'sharp'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import {testEmailAdapter} from "./helpers/testEmailAdapter"
import {seed} from "./seed" import {seed} from "./seed"
import { notificationsPlugin } from '@xtr-dev/payload-notifications' import { notificationsPlugin } from '@xtr-dev/payload-notifications'
import {sqliteAdapter} from "@payloadcms/db-sqlite"
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -16,144 +16,128 @@ if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname process.env.ROOT_DIR = dirname
} }
const buildConfigWithMemoryDB = async () => { export default buildConfig({
if (!process.env.DATABASE_URI) { admin: {
// Use a simple memory server instead of replica set for better stability importMap: {
const { MongoMemoryServer } = await import('mongodb-memory-server') baseDir: path.resolve(dirname),
const memoryDB = await MongoMemoryServer.create({
instance: {
dbName: 'payloadmemory',
},
})
process.env.DATABASE_URI = memoryDB.getUri()
}
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
}, },
collections: [ },
// Users collection with roles for authentication collections: [
{ // Users collection with roles for authentication
slug: 'users', {
auth: true, slug: 'users',
admin: { auth: true,
useAsTitle: 'email', admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'role',
type: 'select',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Customer', value: 'customer' },
],
defaultValue: 'customer',
required: true,
}, },
fields: [ {
{ name: 'firstName',
name: 'role', type: 'text',
type: 'select', label: 'First Name',
options: [ },
{ label: 'Admin', value: 'admin' }, {
{ label: 'Customer', value: 'customer' }, name: 'lastName',
], type: 'text',
defaultValue: 'customer', label: 'Last Name',
required: true, },
}, ],
{
name: 'firstName',
type: 'text',
label: 'First Name',
},
{
name: 'lastName',
type: 'text',
label: 'Last Name',
},
],
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
}, },
plugins: [ ],
// Demo of the notifications plugin with relationships and channels db: sqliteAdapter({
notificationsPlugin({ client: {
channels: [ url: process.env.DATABASE_URI || 'file:./dev.db',
{ },
id: 'general', }),
name: 'General Notifications', editor: lexicalEditor(),
description: 'General updates and announcements', onInit: async (payload) => {
}, await seed(payload)
{ },
id: 'orders', plugins: [
name: 'Order Updates', // Demo of the notifications plugin with relationships and channels
description: 'Order status changes and shipping notifications', notificationsPlugin({
}, channels: [
{ {
id: 'products', id: 'general',
name: 'Product Updates', name: 'General Notifications',
description: 'New products, restocks, and price changes', description: 'General updates and announcements',
}, },
{ {
id: 'marketing', id: 'orders',
name: 'Marketing & Promotions', name: 'Order Updates',
description: 'Special offers, sales, and promotional content', description: 'Order status changes and shipping notifications',
} },
], {
webPush: { id: 'products',
enabled: true, name: 'Product Updates',
autoPush: true, // Enable automatic push notifications description: 'New products, restocks, and price changes',
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || 'BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L', },
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key-here', {
vapidSubject: 'mailto:test@example.com', id: 'marketing',
// Custom notification transformer for demo name: 'Marketing & Promotions',
transformNotification: (notification: any) => { description: 'Special offers, sales, and promotional content',
const title = notification.title || 'New Notification' }
],
webPush: {
enabled: true,
autoPush: true, // Enable automatic push notifications
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || 'BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L',
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key-here',
vapidSubject: 'mailto:test@example.com',
// Custom notification transformer for demo
transformNotification: (notification: any) => {
const title = notification.title || 'New Notification'
// Extract text from rich text message // Extract text from rich text message
let body = 'You have a new notification' let body = 'You have a new notification'
if (notification.message && Array.isArray(notification.message)) { if (notification.message && Array.isArray(notification.message)) {
const textParts: string[] = [] const textParts: string[] = []
notification.message.forEach((block: any) => { notification.message.forEach((block: any) => {
if (block.children && Array.isArray(block.children)) { if (block.children && Array.isArray(block.children)) {
block.children.forEach((child: any) => { block.children.forEach((child: any) => {
if (child.text) textParts.push(child.text) if (child.text) textParts.push(child.text)
}) })
}
})
if (textParts.length > 0) {
body = textParts.join(' ').substring(0, 120) + (textParts.join(' ').length > 120 ? '...' : '')
} }
})
if (textParts.length > 0) {
body = textParts.join(' ').substring(0, 120) + (textParts.join(' ').length > 120 ? '...' : '')
} }
}
return { return {
title: `🔔 ${title}`, title: `🔔 ${title}`,
body, body,
icon: '/icons/notification-icon.png', icon: '/icons/notification-icon.png',
badge: '/icons/notification-badge.png', badge: '/icons/notification-badge.png',
data: { data: {
notificationId: notification.id, notificationId: notification.id,
url: `/admin/collections/notifications/${notification.id}`, url: `/admin/collections/notifications/${notification.id}`,
createdAt: notification.createdAt, createdAt: notification.createdAt,
}, },
actions: [ actions: [
{ action: 'view', title: 'View in Admin', icon: '/icons/view.png' }, { action: 'view', title: 'View in Admin', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' } { action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
], ],
tag: `notification-${notification.id}`, tag: `notification-${notification.id}`,
requireInteraction: false, requireInteraction: false,
}
} }
} }
}), }
], }),
secret: process.env.PAYLOAD_SECRET || 'test-secret_key', ],
sharp, secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
typescript: { sharp,
outputFile: path.resolve(dirname, 'payload-types.ts'), typescript: {
}, outputFile: path.resolve(dirname, 'payload-types.ts'),
}) },
} })
export default buildConfigWithMemoryDB()

View File

@@ -13,6 +13,7 @@
], ],
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"rootDir": "../",
"paths": { "paths": {
"@payload-config": [ "@payload-config": [
"./payload.config.ts" "./payload.config.ts"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-notifications", "name": "@xtr-dev/payload-notifications",
"version": "0.0.1", "version": "0.0.2",
"description": "A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items", "description": "A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -188,9 +188,9 @@ export function createNotificationsCollection(options: NotificationsPluginOption
return return
} }
let recipientId: string let recipientId: string|number
if (typeof doc.recipient === 'string') { if (typeof doc.recipient === 'string' || typeof doc.recipient === 'number') {
recipientId = doc.recipient recipientId = doc.recipient
} else if (doc.recipient?.id) { } else if (doc.recipient?.id) {
recipientId = doc.recipient.id recipientId = doc.recipient.id

View File

@@ -36,7 +36,7 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
const pushManager = new WebPushManager(webPushConfig, req.payload) const pushManager = new WebPushManager(webPushConfig, req.payload)
await pushManager.subscribe( await pushManager.subscribe(
String(req.user.id), req.user.id,
subscription, subscription,
userAgent, userAgent,
channels channels

View File

@@ -57,7 +57,7 @@ export class WebPushManager {
* Send push notification to all active subscriptions for a recipient * Send push notification to all active subscriptions for a recipient
*/ */
public async sendToRecipient( public async sendToRecipient(
recipientId: string, recipientId: string|number,
title: string, title: string,
body: string, body: string,
options?: { options?: {
@@ -186,7 +186,7 @@ export class WebPushManager {
* Subscribe a user to push notifications * Subscribe a user to push notifications
*/ */
public async subscribe( public async subscribe(
userId: string, userId: string | number,
subscription: PushSubscription, subscription: PushSubscription,
userAgent?: string, userAgent?: string,
channels?: string[] channels?: string[]
@@ -216,15 +216,6 @@ export class WebPushManager {
}, },
}) })
} else { } else {
console.info({
user: userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
channels,
isActive: true,
})
// Create new subscription // Create new subscription
await this.payload.create({ await this.payload.create({
collection: 'push-subscriptions', collection: 'push-subscriptions',