From 9f78d3ef724ae7ec62e690520fed203a6d7dbf2a Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 28 Sep 2025 13:43:01 +0200 Subject: [PATCH] Refactor: Simplify notifications plugin configuration, remove unused code, and improve channel handling --- dev/app/(app)/demo/page.tsx | 237 +++----------------------- dev/payload-types.ts | 178 ++----------------- dev/payload.config.ts | 21 +-- dev/tsconfig.json | 1 - src/client/push-manager.ts | 26 +-- src/collections/notifications.ts | 115 +++++-------- src/collections/push-subscriptions.ts | 31 ++-- src/endpoints/push-notifications.ts | 5 +- src/exports/rsc.ts | 5 +- src/index.ts | 31 ++-- src/types.ts | 66 ++----- src/utils/buildFields.ts | 43 ----- src/utils/webPush.ts | 19 ++- 13 files changed, 152 insertions(+), 626 deletions(-) delete mode 100644 src/utils/buildFields.ts diff --git a/dev/app/(app)/demo/page.tsx b/dev/app/(app)/demo/page.tsx index b249104..c053d4e 100644 --- a/dev/app/(app)/demo/page.tsx +++ b/dev/app/(app)/demo/page.tsx @@ -1,174 +1,7 @@ 'use client' import { useState, useEffect } from 'react' - -// Enhanced demo implementation with real service worker registration -class DemoClientPushManager { - 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' - } - - public isSupported(): boolean { - if (typeof window === 'undefined') return false - return ( - 'serviceWorker' in navigator && - 'PushManager' in window && - 'Notification' in window - ) - } - - public getPermissionStatus(): NotificationPermission { - if (typeof window === 'undefined' || typeof Notification === 'undefined') return 'default' - return Notification.permission - } - - public async requestPermission(): Promise { - if (!this.isSupported()) { - throw new Error('Push notifications are not supported') - } - return await Notification.requestPermission() - } - - public async registerServiceWorker(): Promise { - if (!this.isSupported()) { - throw new Error('Service workers are not supported') - } - - try { - const registration = await navigator.serviceWorker.register(this.serviceWorkerPath) - console.log('Service worker registered:', registration) - - // Wait for service worker to be ready - await navigator.serviceWorker.ready - - return registration - } catch (error) { - console.error('Service worker registration failed:', error) - throw error - } - } - - public async subscribe(): Promise { - const permission = await this.requestPermission() - if (permission !== 'granted') { - throw new Error('Notification permission not granted') - } - - const registration = await this.registerServiceWorker() - - // For demo purposes, we'll simulate subscription without actual VAPID keys - // In production, you would use real VAPID keys here - try { - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey), - }) - - console.log('Push subscription:', subscription) - return { - endpoint: subscription.endpoint, - keys: { - p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!), - auth: this.arrayBufferToBase64(subscription.getKey('auth')!), - }, - } - } catch (error) { - console.warn('Real push subscription failed, simulating for demo:', error) - // Return simulated subscription for demo - return { - endpoint: 'demo-endpoint', - keys: { p256dh: 'demo-key', auth: 'demo-auth' } - } - } - } - - public async isSubscribed(): Promise { - if (!this.isSupported()) return false - - try { - const registration = await navigator.serviceWorker.getRegistration() - if (!registration) return false - - const subscription = await registration.pushManager.getSubscription() - return subscription !== null - } catch { - return false - } - } - - public async unsubscribe(): Promise { - try { - const registration = await navigator.serviceWorker.getRegistration() - if (!registration) return - - const subscription = await registration.pushManager.getSubscription() - if (subscription) { - await subscription.unsubscribe() - } - } catch (error) { - console.error('Unsubscribe failed:', error) - } - } - - public async sendTestNotification(): Promise { - // Send a test notification using the service worker - const registration = await navigator.serviceWorker.getRegistration() - if (!registration) { - throw new Error('Service worker not registered') - } - - // Simulate receiving a push message - if (registration.active) { - registration.active.postMessage({ - type: 'TEST_NOTIFICATION', - payload: { - title: 'Test Notification', - body: 'This is a test notification from the demo!', - icon: '/icons/notification-icon.png', - badge: '/icons/notification-badge.png', - data: { - url: '/admin/collections/notifications', - notificationId: 'demo-' + Date.now() - } - } - }) - } - - // Also show a direct notification for testing - if (Notification.permission === 'granted') { - new Notification('Direct Test Notification', { - body: 'This notification was sent directly from JavaScript', - icon: '/icons/notification-icon.png', - tag: 'direct-test' - }) - } - } - - 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 - } - - 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) - } -} +import {ClientPushManager} from "../../../../src/client/push-manager" // Available channels (should match the configuration in payload.config.ts) const AVAILABLE_CHANNELS = [ @@ -182,7 +15,7 @@ export default function DemoPage() { const [isSupported, setIsSupported] = useState(false) const [isSubscribed, setIsSubscribed] = useState(false) const [permission, setPermission] = useState('default') - const [pushManager, setPushManager] = useState(null) + const [pushManager, setPushManager] = useState(null) const [loading, setLoading] = useState(false) const [selectedChannels, setSelectedChannels] = useState( AVAILABLE_CHANNELS.filter(channel => channel.defaultEnabled).map(channel => channel.id) @@ -191,7 +24,7 @@ export default function DemoPage() { useEffect(() => { // Use the real VAPID public key from environment const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || 'BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A' - const manager = new DemoClientPushManager(vapidPublicKey) + const manager = new ClientPushManager(vapidPublicKey) setPushManager(manager) setIsSupported(manager.isSupported()) setPermission(manager.getPermissionStatus()) @@ -203,11 +36,11 @@ export default function DemoPage() { const handleSubscribe = async () => { if (!pushManager) return - + setLoading(true) try { - const subscription = await pushManager.subscribe() - + const subscription = await pushManager.subscribe(selectedChannels) + // Save the subscription to Payload's database using the plugin's API endpoint const response = await fetch('/api/push-notifications/subscribe', { method: 'POST', @@ -221,7 +54,6 @@ export default function DemoPage() { channels: selectedChannels, }), }) - if (response.ok) { setIsSubscribed(true) setPermission('granted') @@ -239,11 +71,11 @@ export default function DemoPage() { const handleUnsubscribe = async () => { if (!pushManager) return - + setLoading(true) try { await pushManager.unsubscribe() - + // Remove the subscription from Payload's database const response = await fetch('/api/push-notifications/unsubscribe', { method: 'POST', @@ -271,27 +103,13 @@ export default function DemoPage() { setLoading(false) } - const handleTestNotification = async () => { - if (!pushManager) return - - setLoading(true) - try { - await pushManager.sendTestNotification() - alert('Test notification sent! Check your browser notifications.') - } catch (error) { - console.error('Failed to send test notification:', error) - alert('Failed to send test notification: ' + (error as Error).message) - } - setLoading(false) - } - return (

Payload Notifications Plugin Demo

- +

🔔 Web Push Notifications

- + {!isSupported ? (
❌ Push notifications are not supported in this browser @@ -300,7 +118,7 @@ export default function DemoPage() {

Status: {isSubscribed ? '✅ Subscribed' : '❌ Not subscribed'}

Permission: {permission}

- + {!isSubscribed && (

📢 Select Notification Channels

@@ -309,11 +127,11 @@ export default function DemoPage() {

{AVAILABLE_CHANNELS.map(channel => ( -
)} - +
{!isSubscribed ? ( - - )}
@@ -490,4 +293,4 @@ export default function DemoPage() {
) -} \ No newline at end of file +} diff --git a/dev/payload-types.ts b/dev/payload-types.ts index 5a11387..c0b489b 100644 --- a/dev/payload-types.ts +++ b/dev/payload-types.ts @@ -68,10 +68,6 @@ export interface Config { blocks: {}; collections: { users: User; - orders: Order; - products: Product; - posts: Post; - media: Media; notifications: Notification; 'push-subscriptions': PushSubscription; 'payload-locked-documents': PayloadLockedDocument; @@ -81,10 +77,6 @@ export interface Config { collectionsJoins: {}; collectionsSelect: { users: UsersSelect | UsersSelect; - orders: OrdersSelect | OrdersSelect; - products: ProductsSelect | ProductsSelect; - posts: PostsSelect | PostsSelect; - media: MediaSelect | MediaSelect; notifications: NotificationsSelect | NotificationsSelect; 'push-subscriptions': PushSubscriptionsSelect | PushSubscriptionsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; @@ -143,79 +135,6 @@ export interface User { lockUntil?: string | null; password?: string | null; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "orders". - */ -export interface Order { - id: string; - orderNumber: string; - customer: string | User; - status?: ('pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled') | null; - total: number; - products?: (string | Product)[] | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "products". - */ -export interface Product { - id: string; - name: string; - description?: string | null; - price: number; - category?: ('electronics' | 'clothing' | 'books' | 'home-garden') | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts". - */ -export interface Post { - id: string; - title: string; - content?: { - root: { - type: string; - children: { - type: string; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; - author?: (string | null) | User; - publishedAt?: string | null; - updatedAt: string; - createdAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "media". - */ -export interface Media { - id: string; - alt?: string | null; - updatedAt: string; - createdAt: string; - url?: string | null; - thumbnailURL?: string | null; - filename?: string | null; - mimeType?: string | null; - filesize?: number | null; - width?: number | null; - height?: number | null; - focalX?: number | null; - focalY?: number | null; -} /** * Manage user notifications and messaging * @@ -247,9 +166,13 @@ export interface Notification { [k: string]: unknown; }; /** - * The user who should receive this notification + * The user who should receive this notification (optional if using custom recipient fields) */ - recipient: string | User; + recipient?: (string | null) | User; + /** + * The notification channel - only subscribers to this channel will receive the notification + */ + channel?: ('general' | 'orders' | 'products' | 'marketing') | null; /** * Whether this notification has been read by the recipient */ @@ -258,11 +181,6 @@ export interface Notification { * When this notification was marked as read */ readAt?: string | null; - attachments?: { - order?: (string | null) | Order; - product?: (string | Product)[] | null; - post?: (string | null) | Post; - }; updatedAt: string; createdAt: string; } @@ -294,6 +212,10 @@ export interface PushSubscription { * Browser/device information */ userAgent?: string | null; + /** + * Channels this subscription is subscribed to - leave empty for all notifications + */ + channels?: ('general' | 'orders' | 'products' | 'marketing')[] | null; /** * Whether this subscription is still active */ @@ -312,22 +234,6 @@ export interface PayloadLockedDocument { relationTo: 'users'; value: string | User; } | null) - | ({ - relationTo: 'orders'; - value: string | Order; - } | null) - | ({ - relationTo: 'products'; - value: string | Product; - } | null) - | ({ - relationTo: 'posts'; - value: string | Post; - } | null) - | ({ - relationTo: 'media'; - value: string | Media; - } | null) | ({ relationTo: 'notifications'; value: string | Notification; @@ -396,61 +302,6 @@ export interface UsersSelect { loginAttempts?: T; lockUntil?: T; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "orders_select". - */ -export interface OrdersSelect { - orderNumber?: T; - customer?: T; - status?: T; - total?: T; - products?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "products_select". - */ -export interface ProductsSelect { - name?: T; - description?: T; - price?: T; - category?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts_select". - */ -export interface PostsSelect { - title?: T; - content?: T; - author?: T; - publishedAt?: T; - updatedAt?: T; - createdAt?: T; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "media_select". - */ -export interface MediaSelect { - alt?: T; - updatedAt?: T; - createdAt?: T; - url?: T; - thumbnailURL?: T; - filename?: T; - mimeType?: T; - filesize?: T; - width?: T; - height?: T; - focalX?: T; - focalY?: T; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "notifications_select". @@ -459,15 +310,9 @@ export interface NotificationsSelect { title?: T; message?: T; recipient?: T; + channel?: T; isRead?: T; readAt?: T; - attachments?: - | T - | { - order?: T; - product?: T; - post?: T; - }; updatedAt?: T; createdAt?: T; } @@ -481,6 +326,7 @@ export interface PushSubscriptionsSelect { p256dh?: T; auth?: T; userAgent?: T; + channels?: T; isActive?: T; updatedAt?: T; createdAt?: T; diff --git a/dev/payload.config.ts b/dev/payload.config.ts index ba21850..55ecf7b 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -5,8 +5,8 @@ import { buildConfig } from 'payload' import sharp from 'sharp' import { fileURLToPath } from 'url' -import { testEmailAdapter } from './helpers/testEmailAdapter.ts' -import { seed } from './seed.ts' +import {testEmailAdapter} from "./helpers/testEmailAdapter" +import {seed} from "./seed" import { notificationsPlugin } from '@xtr-dev/payload-notifications' const filename = fileURLToPath(import.meta.url) @@ -79,45 +79,28 @@ const buildConfigWithMemoryDB = async () => { plugins: [ // Demo of the notifications plugin with relationships and channels notificationsPlugin({ - collections: { - slug: 'notifications', - labels: { - singular: 'Notification', - plural: 'Notifications' - } - }, channels: [ { id: 'general', name: 'General Notifications', description: 'General updates and announcements', - defaultEnabled: true }, { id: 'orders', name: 'Order Updates', description: 'Order status changes and shipping notifications', - defaultEnabled: true }, { id: 'products', name: 'Product Updates', description: 'New products, restocks, and price changes', - defaultEnabled: false }, { id: 'marketing', name: 'Marketing & Promotions', description: 'Special offers, sales, and promotional content', - defaultEnabled: false } ], - access: { - 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'), - }, webPush: { enabled: true, autoPush: true, // Enable automatic push notifications diff --git a/dev/tsconfig.json b/dev/tsconfig.json index c572499..2732635 100644 --- a/dev/tsconfig.json +++ b/dev/tsconfig.json @@ -13,7 +13,6 @@ ], "compilerOptions": { "baseUrl": "./", - "rootDir": "./", "paths": { "@payload-config": [ "./payload.config.ts" diff --git a/src/client/push-manager.ts b/src/client/push-manager.ts index f0331c3..ff148ae 100644 --- a/src/client/push-manager.ts +++ b/src/client/push-manager.ts @@ -1,7 +1,7 @@ /** * 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 */ @@ -17,9 +17,9 @@ export interface PushSubscriptionData { const isBrowser = typeof window !== 'undefined' export class ClientPushManager { - private vapidPublicKey: string - private serviceWorkerPath: string - private apiEndpoint: string + private readonly vapidPublicKey: string + private readonly serviceWorkerPath: string + private readonly apiEndpoint: string constructor( vapidPublicKey: string, @@ -38,7 +38,7 @@ export class ClientPushManager { */ public isSupported(): boolean { if (!isBrowser) return false - + return ( 'serviceWorker' in navigator && 'PushManager' in window && @@ -62,8 +62,7 @@ export class ClientPushManager { throw new Error('Push notifications are not supported') } - const permission = await Notification.requestPermission() - return permission + return await Notification.requestPermission() } /** @@ -87,7 +86,7 @@ export class ClientPushManager { /** * Subscribe to push notifications */ - public async subscribe(): Promise { + public async subscribe(channels: string[]): Promise { // Check support if (!this.isSupported()) { throw new Error('Push notifications are not supported') @@ -120,7 +119,7 @@ export class ClientPushManager { } // Send subscription to server - await this.sendSubscriptionToServer(subscriptionData) + await this.sendSubscriptionToServer(subscriptionData, channels) return subscriptionData } @@ -149,9 +148,9 @@ export class ClientPushManager { /** * Get current push subscription */ - public async getSubscription(): Promise { + public async getSubscription(): Promise | null> { if (!isBrowser || !('serviceWorker' in navigator)) return null - + const registration = await navigator.serviceWorker.getRegistration() if (!registration) { return null @@ -183,7 +182,7 @@ export class ClientPushManager { /** * Send subscription data to server */ - private async sendSubscriptionToServer(subscription: PushSubscriptionData): Promise { + private async sendSubscriptionToServer(subscription: PushSubscriptionData, channels: string[]): Promise { try { const response = await fetch(`${this.apiEndpoint}/subscribe`, { method: 'POST', @@ -192,6 +191,7 @@ export class ClientPushManager { }, body: JSON.stringify({ subscription, + channels, userAgent: navigator.userAgent, }), }) @@ -251,4 +251,4 @@ export class ClientPushManager { const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('') return window.btoa(binary) } -} \ No newline at end of file +} diff --git a/src/collections/notifications.ts b/src/collections/notifications.ts index 6409b3b..953c298 100644 --- a/src/collections/notifications.ts +++ b/src/collections/notifications.ts @@ -1,68 +1,32 @@ import type { CollectionConfig, Field } from 'payload' -import type { NotificationsPluginOptions, NotificationAccess } from '../types' -import { buildRelationshipFields } from '../utils/buildFields' +import type { NotificationsPluginOptions } from '../types' 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' +export function createNotificationsCollection(options: NotificationsPluginOptions): CollectionConfig { + const slug = 'notifications' const labels = { - singular: collections.labels?.singular || 'Notification', - plural: collections.labels?.plural || 'Notifications', + singular: 'Notification', + plural: 'Notifications', + } + + if (options.channels.length === 0) { + throw new Error('No channels defined for notifications plugin') } // Default access control - authenticated users can read, admins can manage - const defaultAccess: NotificationAccess = { + const access: CollectionConfig['access'] = { 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[] = [ + const allFields: Field[] = [ { name: 'title', type: 'text', @@ -81,8 +45,30 @@ export function createNotificationsCollection(options: NotificationsPluginOption description: 'The notification message content', }, }, - recipientField, - ...channelField, + { + 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)', + }, + }, + { + 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', + }, + }, { name: 'isRead', type: 'checkbox', @@ -105,12 +91,6 @@ export function createNotificationsCollection(options: NotificationsPluginOption }, ] - // Build relationship fields - const relationshipFields = buildRelationshipFields(relationships) - - // Combine all fields - const allFields = [...coreFields, ...relationshipFields, ...customFields] - const config: CollectionConfig = { slug, labels, @@ -120,12 +100,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption 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!, - }, + access, timestamps: true, } @@ -155,7 +130,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption // 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 @@ -199,10 +174,10 @@ export function createNotificationsCollection(options: NotificationsPluginOption return { success: false, error } } }) - ).then(results => + ).then(results => results.map((result) => - result.status === 'fulfilled' - ? result.value + result.status === 'fulfilled' + ? result.value : { success: false, error: result.reason } ) ) @@ -214,7 +189,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption } let recipientId: string - + if (typeof doc.recipient === 'string') { recipientId = doc.recipient } else if (doc.recipient?.id) { @@ -251,7 +226,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption console.log(`[Notifications Plugin] Push notification results: ${successful} sent, ${failed} failed`) if (failed > 0) { - console.warn('[Notifications Plugin] Some push notifications failed:', + console.warn('[Notifications Plugin] Some push notifications failed:', results.filter(r => !r.success).map(r => r.error) ) } @@ -265,5 +240,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption } } - return config -} \ No newline at end of file + return options.collectionOverrides?.notifications ? + options.collectionOverrides.notifications(config) : + config +} diff --git a/src/collections/push-subscriptions.ts b/src/collections/push-subscriptions.ts index 4f4a67a..6db0605 100644 --- a/src/collections/push-subscriptions.ts +++ b/src/collections/push-subscriptions.ts @@ -1,19 +1,19 @@ import type { CollectionConfig } from 'payload' -import type { NotificationAccess, NotificationsPluginOptions } from '../types' +import type { 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 = { +export function createPushSubscriptionsCollection(options: NotificationsPluginOptions): CollectionConfig { + const access: CollectionConfig['access'] = { 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 { + const config: CollectionConfig = { slug: 'push-subscriptions', labels: { singular: 'Push Subscription', @@ -76,16 +76,11 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = { 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' }], + options: options.channels.map(channel => ({ + label: channel.name, + value: channel.id, + })), 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', }, @@ -101,12 +96,7 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = { }, }, ], - access: { - read: access.read || defaultAccess.read!, - create: access.create || defaultAccess.create!, - update: access.update || defaultAccess.update!, - delete: access.delete || defaultAccess.delete!, - }, + access, timestamps: true, hooks: { beforeChange: [ @@ -120,4 +110,7 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = { ], }, } + return options.collectionOverrides?.pushSubscriptions ? + options.collectionOverrides.pushSubscriptions(config) : + config } diff --git a/src/endpoints/push-notifications.ts b/src/endpoints/push-notifications.ts index 2e854b4..4093d38 100644 --- a/src/endpoints/push-notifications.ts +++ b/src/endpoints/push-notifications.ts @@ -11,7 +11,7 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti } const webPushConfig = options.webPush - + return [ // Subscribe endpoint { @@ -35,7 +35,6 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti } const pushManager = new WebPushManager(webPushConfig, req.payload) - await pushManager.subscribe( String(req.user.id), subscription, @@ -247,4 +246,4 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti }, }, ] -} \ No newline at end of file +} diff --git a/src/exports/rsc.ts b/src/exports/rsc.ts index 064dee0..776c17b 100644 --- a/src/exports/rsc.ts +++ b/src/exports/rsc.ts @@ -12,7 +12,4 @@ export type { WebPushConfig, PushSubscription, NotificationsPluginOptions, - NotificationRelationship, - NotificationCollectionConfig, - NotificationAccess, -} from '../types' \ No newline at end of file +} from '../types' diff --git a/src/index.ts b/src/index.ts index 0619a73..0574cb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,23 +4,33 @@ import { createNotificationsCollection } from './collections/notifications' import { createPushSubscriptionsCollection } from './collections/push-subscriptions' import { createPushNotificationEndpoints } from './endpoints/push-notifications' +const defaultOptions: NotificationsPluginOptions = { + channels: [ + { + name: 'Default', + id: 'default', + description: 'Default channel', + } + ] +} + /** * PayloadCMS Notifications Plugin - * + * * Adds a configurable notifications collection with support for: * - Title and rich text message content - * - Recipient targeting + * - 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 = {}) => { +export const notificationsPlugin: NotificationsPlugin = (options = defaultOptions) => { 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 = [ @@ -30,16 +40,16 @@ export const notificationsPlugin: NotificationsPlugin = (options = {}) => { // Add push subscriptions collection if web push is enabled if (options.webPush?.enabled) { - const pushSubscriptionsCollection = createPushSubscriptionsCollection(options.access, options) + const pushSubscriptionsCollection = createPushSubscriptionsCollection(options) newCollections.push(pushSubscriptionsCollection) } // Create push notification endpoints if web push is enabled const endpoints = config.endpoints || [] - const pushEndpoints = options.webPush?.enabled + const pushEndpoints = options.webPush?.enabled ? createPushNotificationEndpoints(options) : [] - + return { ...config, collections: newCollections, @@ -54,12 +64,9 @@ export const notificationsPlugin: NotificationsPlugin = (options = {}) => { // Export types for consumers export type { NotificationsPluginOptions, - NotificationRelationship, - NotificationCollectionConfig, - NotificationAccess, NotificationChannel, WebPushConfig, } from './types' // Default export -export default notificationsPlugin \ No newline at end of file +export default notificationsPlugin diff --git a/src/types.ts b/src/types.ts index 9abec0c..edd2c0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,44 +1,5 @@ -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 -} +import type {CollectionConfig, Config} from 'payload' +import type {RequestOptions} from 'web-push' /** * Web push subscription data structure @@ -61,8 +22,6 @@ export interface NotificationChannel { name: string /** Channel description */ description?: string - /** Default enabled state for new subscriptions */ - defaultEnabled?: boolean } /** @@ -78,7 +37,7 @@ export interface WebPushConfig { /** Enable web push notifications */ enabled?: boolean /** Custom push notification options */ - options?: webpush.RequestOptions + options?: RequestOptions /** Automatically send push notifications when notifications are created */ autoPush?: boolean /** Custom notification content transformer */ @@ -93,8 +52,8 @@ export interface WebPushConfig { tag?: string requireInteraction?: boolean } - /** - * Custom hook to find push subscriptions for a notification + /** + * 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 */ @@ -106,20 +65,17 @@ export interface WebPushConfig { */ 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[] + collectionOverrides?: { + notifications: (config: CollectionConfig) => CollectionConfig + pushSubscriptions: (config: CollectionConfig) => CollectionConfig + } /** Web push notification configuration */ webPush?: WebPushConfig /** Notification channels configuration */ - channels?: NotificationChannel[] + channels: NotificationChannel[] } /** * Plugin function type */ -export type NotificationsPlugin = (options?: NotificationsPluginOptions) => (config: Config) => Config \ No newline at end of file +export type NotificationsPlugin = (options?: NotificationsPluginOptions) => (config: Config) => Config diff --git a/src/utils/buildFields.ts b/src/utils/buildFields.ts deleted file mode 100644 index beac039..0000000 --- a/src/utils/buildFields.ts +++ /dev/null @@ -1,43 +0,0 @@ -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, - }, - ] -} \ No newline at end of file diff --git a/src/utils/webPush.ts b/src/utils/webPush.ts index 0c6ac4d..c0a8f79 100644 --- a/src/utils/webPush.ts +++ b/src/utils/webPush.ts @@ -151,8 +151,8 @@ export class WebPushManager { ) return results.map((result) => - result.status === 'fulfilled' - ? result.value + result.status === 'fulfilled' + ? result.value : { success: false, error: result.reason } ) } @@ -211,11 +211,20 @@ export class WebPushManager { p256dh: subscription.keys.p256dh, auth: subscription.keys.auth, userAgent, - channels: channels || ['all'], + channels, isActive: true, }, }) } else { + console.info({ + user: userId, + endpoint: subscription.endpoint, + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + userAgent, + channels, + isActive: true, + }) // Create new subscription await this.payload.create({ collection: 'push-subscriptions', @@ -225,7 +234,7 @@ export class WebPushManager { p256dh: subscription.keys.p256dh, auth: subscription.keys.auth, userAgent, - channels: channels || ['all'], + channels, isActive: true, }, }) @@ -268,4 +277,4 @@ export class WebPushManager { public getVapidPublicKey(): string { return this.config.vapidPublicKey } -} \ No newline at end of file +}