8 Commits

21 changed files with 394 additions and 988 deletions

43
.github/workflows/pr-version-check.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: PR Version Check
on:
pull_request:
branches:
- main
types: [opened, synchronize]
jobs:
version-check:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get PR branch package.json version
id: pr-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Get main branch package.json version
id: main-version
run: |
git checkout main
echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Compare versions
run: |
PR_VERSION="${{ steps.pr-version.outputs.version }}"
MAIN_VERSION="${{ steps.main-version.outputs.version }}"
echo "PR branch version: $PR_VERSION"
echo "Main branch version: $MAIN_VERSION"
if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
echo "❌ Version must be updated in package.json"
echo "Current version: $MAIN_VERSION"
echo "Please increment the version number before merging to main"
exit 1
else
echo "✅ Version has been updated from $MAIN_VERSION to $PR_VERSION"
fi

View File

@@ -0,0 +1,49 @@
name: Publish to NPM
on:
push:
branches:
- main
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test
- name: Run build
run: pnpm build
- name: Get package version
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Create and push git tag
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git tag -a "v${{ steps.package-version.outputs.version }}" -m "Release v${{ steps.package-version.outputs.version }}"
git push origin "v${{ steps.package-version.outputs.version }}"
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

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

@@ -1,5 +1,7 @@
# @xtr-dev/payload-notifications # @xtr-dev/payload-notifications
[![npm version](https://badge.fury.io/js/@xtr-dev%2Fpayload-notifications.svg)](https://www.npmjs.com/package/@xtr-dev/payload-notifications)
A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items. A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items.
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use. ⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.

View File

@@ -1,174 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import {ClientPushManager} from "../../../../src/client/push-manager"
// 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<NotificationPermission> {
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
return await Notification.requestPermission()
}
public async registerServiceWorker(): Promise<ServiceWorkerRegistration> {
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<any> {
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<boolean> {
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<void> {
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<void> {
// 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)
}
}
// Available channels (should match the configuration in payload.config.ts) // Available channels (should match the configuration in payload.config.ts)
const AVAILABLE_CHANNELS = [ const AVAILABLE_CHANNELS = [
@@ -182,7 +15,7 @@ export default function DemoPage() {
const [isSupported, setIsSupported] = useState(false) const [isSupported, setIsSupported] = useState(false)
const [isSubscribed, setIsSubscribed] = useState(false) const [isSubscribed, setIsSubscribed] = useState(false)
const [permission, setPermission] = useState<NotificationPermission>('default') const [permission, setPermission] = useState<NotificationPermission>('default')
const [pushManager, setPushManager] = useState<DemoClientPushManager | null>(null) const [pushManager, setPushManager] = useState<ClientPushManager | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [selectedChannels, setSelectedChannels] = useState<string[]>( const [selectedChannels, setSelectedChannels] = useState<string[]>(
AVAILABLE_CHANNELS.filter(channel => channel.defaultEnabled).map(channel => channel.id) AVAILABLE_CHANNELS.filter(channel => channel.defaultEnabled).map(channel => channel.id)
@@ -191,7 +24,7 @@ export default function DemoPage() {
useEffect(() => { useEffect(() => {
// Use the real VAPID public key from environment // Use the real VAPID public key from environment
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || 'BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A' 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) setPushManager(manager)
setIsSupported(manager.isSupported()) setIsSupported(manager.isSupported())
setPermission(manager.getPermissionStatus()) setPermission(manager.getPermissionStatus())
@@ -206,7 +39,7 @@ export default function DemoPage() {
setLoading(true) setLoading(true)
try { 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 // Save the subscription to Payload's database using the plugin's API endpoint
const response = await fetch('/api/push-notifications/subscribe', { const response = await fetch('/api/push-notifications/subscribe', {
@@ -221,7 +54,6 @@ export default function DemoPage() {
channels: selectedChannels, channels: selectedChannels,
}), }),
}) })
if (response.ok) { if (response.ok) {
setIsSubscribed(true) setIsSubscribed(true)
setPermission('granted') setPermission('granted')
@@ -243,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)
@@ -271,20 +84,6 @@ export default function DemoPage() {
setLoading(false) 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 ( return (
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
<h1>Payload Notifications Plugin Demo</h1> <h1>Payload Notifications Plugin Demo</h1>
@@ -382,21 +181,6 @@ export default function DemoPage() {
> >
{loading ? 'Unsubscribing...' : 'Disable Notifications'} {loading ? 'Unsubscribing...' : 'Disable Notifications'}
</button> </button>
<button
onClick={handleTestNotification}
disabled={loading}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{loading ? 'Sending...' : 'Send Test Notification'}
</button>
</> </>
)} )}
</div> </div>

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

@@ -68,10 +68,6 @@ export interface Config {
blocks: {}; blocks: {};
collections: { collections: {
users: User; users: User;
orders: Order;
products: Product;
posts: Post;
media: Media;
notifications: Notification; notifications: Notification;
'push-subscriptions': PushSubscription; 'push-subscriptions': PushSubscription;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
@@ -81,10 +77,6 @@ export interface Config {
collectionsJoins: {}; collectionsJoins: {};
collectionsSelect: { collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
orders: OrdersSelect<false> | OrdersSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
notifications: NotificationsSelect<false> | NotificationsSelect<true>; notifications: NotificationsSelect<false> | NotificationsSelect<true>;
'push-subscriptions': PushSubscriptionsSelect<false> | PushSubscriptionsSelect<true>; 'push-subscriptions': PushSubscriptionsSelect<false> | PushSubscriptionsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@@ -143,79 +135,6 @@ export interface User {
lockUntil?: string | null; lockUntil?: string | null;
password?: 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 * Manage user notifications and messaging
* *
@@ -247,9 +166,13 @@ export interface Notification {
[k: string]: unknown; [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 * Whether this notification has been read by the recipient
*/ */
@@ -258,11 +181,6 @@ export interface Notification {
* When this notification was marked as read * When this notification was marked as read
*/ */
readAt?: string | null; readAt?: string | null;
attachments?: {
order?: (string | null) | Order;
product?: (string | Product)[] | null;
post?: (string | null) | Post;
};
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -294,6 +212,10 @@ export interface PushSubscription {
* Browser/device information * Browser/device information
*/ */
userAgent?: string | null; 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 * Whether this subscription is still active
*/ */
@@ -312,22 +234,6 @@ export interface PayloadLockedDocument {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: string | User;
} | null) } | 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'; relationTo: 'notifications';
value: string | Notification; value: string | Notification;
@@ -396,61 +302,6 @@ export interface UsersSelect<T extends boolean = true> {
loginAttempts?: T; loginAttempts?: T;
lockUntil?: T; lockUntil?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "orders_select".
*/
export interface OrdersSelect<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "notifications_select". * via the `definition` "notifications_select".
@@ -459,15 +310,9 @@ export interface NotificationsSelect<T extends boolean = true> {
title?: T; title?: T;
message?: T; message?: T;
recipient?: T; recipient?: T;
channel?: T;
isRead?: T; isRead?: T;
readAt?: T; readAt?: T;
attachments?:
| T
| {
order?: T;
product?: T;
post?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
@@ -481,6 +326,7 @@ export interface PushSubscriptionsSelect<T extends boolean = true> {
p256dh?: T; p256dh?: T;
auth?: T; auth?: T;
userAgent?: T; userAgent?: T;
channels?: T;
isActive?: T; isActive?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;

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.ts' import {seed} from "./seed"
import { seed } from './seed.ts'
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,161 +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,
},
{
name: 'firstName',
type: 'text',
label: 'First Name',
},
{
name: 'lastName',
type: 'text',
label: 'Last Name',
},
],
}, },
], fields: [
db: mongooseAdapter({ {
ensureIndexes: true, name: 'role',
url: process.env.DATABASE_URI || '', type: 'select',
}), options: [
editor: lexicalEditor(), { label: 'Admin', value: 'admin' },
email: testEmailAdapter, { label: 'Customer', value: 'customer' },
onInit: async (payload) => { ],
await seed(payload) defaultValue: 'customer',
required: true,
},
{
name: 'firstName',
type: 'text',
label: 'First Name',
},
{
name: 'lastName',
type: 'text',
label: 'Last Name',
},
],
}, },
plugins: [ ],
// Demo of the notifications plugin with relationships and channels db: sqliteAdapter({
notificationsPlugin({ client: {
collections: { url: process.env.DATABASE_URI || 'file:./dev.db',
slug: 'notifications', },
labels: { }),
singular: 'Notification', editor: lexicalEditor(),
plural: 'Notifications' onInit: async (payload) => {
} await seed(payload)
},
plugins: [
// Demo of the notifications plugin with relationships and channels
notificationsPlugin({
channels: [
{
id: 'general',
name: 'General Notifications',
description: 'General updates and announcements',
}, },
channels: [ {
{ id: 'orders',
id: 'general', name: 'Order Updates',
name: 'General Notifications', description: 'Order status changes and shipping 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, id: 'products',
autoPush: true, // Enable automatic push notifications name: 'Product Updates',
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || 'BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L', description: 'New products, restocks, and price changes',
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key-here', },
vapidSubject: 'mailto:test@example.com', {
// Custom notification transformer for demo id: 'marketing',
transformNotification: (notification: any) => { name: 'Marketing & Promotions',
const title = notification.title || 'New Notification' description: 'Special offers, sales, and promotional content',
}
],
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,7 +13,7 @@
], ],
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"rootDir": "./", "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": "1.0.0", "version": "0.0.3",
"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",
@@ -31,7 +31,7 @@
"lint": "eslint", "lint": "eslint",
"lint:fix": "eslint ./src --fix", "lint:fix": "eslint ./src --fix",
"prepublishOnly": "pnpm clean && pnpm build", "prepublishOnly": "pnpm clean && pnpm build",
"test": "pnpm test:int && pnpm test:e2e", "test": "echo \"No tests configured yet\"",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:int": "vitest" "test:int": "vitest"
}, },

View File

@@ -17,9 +17,9 @@ export interface PushSubscriptionData {
const isBrowser = typeof window !== 'undefined' const isBrowser = typeof window !== 'undefined'
export class ClientPushManager { export class ClientPushManager {
private vapidPublicKey: string private readonly vapidPublicKey: string
private serviceWorkerPath: string private readonly serviceWorkerPath: string
private apiEndpoint: string private readonly apiEndpoint: string
constructor( constructor(
vapidPublicKey: string, vapidPublicKey: string,
@@ -62,8 +62,7 @@ export class ClientPushManager {
throw new Error('Push notifications are not supported') throw new Error('Push notifications are not supported')
} }
const permission = await Notification.requestPermission() return await Notification.requestPermission()
return permission
} }
/** /**
@@ -87,7 +86,7 @@ export class ClientPushManager {
/** /**
* Subscribe to push notifications * Subscribe to push notifications
*/ */
public async subscribe(): Promise<PushSubscriptionData> { public async subscribe(channels: string[]): Promise<PushSubscriptionData> {
// Check support // Check support
if (!this.isSupported()) { if (!this.isSupported()) {
throw new Error('Push notifications are not supported') throw new Error('Push notifications are not supported')
@@ -120,7 +119,7 @@ export class ClientPushManager {
} }
// Send subscription to server // Send subscription to server
await this.sendSubscriptionToServer(subscriptionData) await this.sendSubscriptionToServer(subscriptionData, channels)
return subscriptionData return subscriptionData
} }
@@ -149,7 +148,7 @@ export class ClientPushManager {
/** /**
* Get current push subscription * Get current push subscription
*/ */
public async getSubscription(): Promise<PushSubscriptionData | null> { public async getSubscription(): Promise<Omit<PushSubscriptionData, 'channels'> | null> {
if (!isBrowser || !('serviceWorker' in navigator)) return null if (!isBrowser || !('serviceWorker' in navigator)) return null
const registration = await navigator.serviceWorker.getRegistration() const registration = await navigator.serviceWorker.getRegistration()
@@ -183,7 +182,7 @@ export class ClientPushManager {
/** /**
* Send subscription data to server * Send subscription data to server
*/ */
private async sendSubscriptionToServer(subscription: PushSubscriptionData): Promise<void> { private async sendSubscriptionToServer(subscription: PushSubscriptionData, channels: string[]): Promise<void> {
try { try {
const response = await fetch(`${this.apiEndpoint}/subscribe`, { const response = await fetch(`${this.apiEndpoint}/subscribe`, {
method: 'POST', method: 'POST',
@@ -192,6 +191,7 @@ export class ClientPushManager {
}, },
body: JSON.stringify({ body: JSON.stringify({
subscription, subscription,
channels,
userAgent: navigator.userAgent, userAgent: navigator.userAgent,
}), }),
}) })

View File

@@ -1,68 +1,32 @@
import type { CollectionConfig, Field } from 'payload' import type { CollectionConfig, Field } from 'payload'
import type { NotificationsPluginOptions, NotificationAccess } from '../types' import type { NotificationsPluginOptions } from '../types'
import { buildRelationshipFields } from '../utils/buildFields'
import { WebPushManager } from '../utils/webPush' import { WebPushManager } from '../utils/webPush'
import { defaultNotificationTransformer } from '../utils/richTextExtractor' import { defaultNotificationTransformer } from '../utils/richTextExtractor'
/** /**
* Creates the notifications collection configuration * Creates the notifications collection configuration
* Includes core fields plus dynamically generated relationship fields
*/ */
export function createNotificationsCollection(options: NotificationsPluginOptions = {}): CollectionConfig { export function createNotificationsCollection(options: NotificationsPluginOptions): CollectionConfig {
const { const slug = 'notifications'
collections = {},
relationships = [],
access = {},
fields: customFields = [],
} = options
const slug = collections.slug || 'notifications'
const labels = { const labels = {
singular: collections.labels?.singular || 'Notification', singular: 'Notification',
plural: collections.labels?.plural || 'Notifications', 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 // Default access control - authenticated users can read, admins can manage
const defaultAccess: NotificationAccess = { const access: CollectionConfig['access'] = {
read: ({ req }: { req: any }) => Boolean(req.user), read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user), create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user), update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'), 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 // Build core fields
const coreFields: Field[] = [ const allFields: Field[] = [
{ {
name: 'title', name: 'title',
type: 'text', type: 'text',
@@ -81,8 +45,30 @@ export function createNotificationsCollection(options: NotificationsPluginOption
description: 'The notification message content', 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', name: 'isRead',
type: 'checkbox', 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 = { const config: CollectionConfig = {
slug, slug,
labels, labels,
@@ -120,12 +100,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption
description: 'Manage user notifications and messaging', description: 'Manage user notifications and messaging',
}, },
fields: allFields, fields: allFields,
access: { access,
read: access.read || defaultAccess.read!,
create: access.create || defaultAccess.create!,
update: access.update || defaultAccess.update!,
delete: access.delete || defaultAccess.delete!,
},
timestamps: true, timestamps: true,
} }
@@ -213,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
@@ -265,5 +240,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption
} }
} }
return config return options.collectionOverrides?.notifications ?
options.collectionOverrides.notifications(config) :
config
} }

View File

@@ -1,19 +1,19 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import type { NotificationAccess, NotificationsPluginOptions } from '../types' import type { NotificationsPluginOptions } from '../types'
/** /**
* Creates a collection to store web push subscriptions * Creates a collection to store web push subscriptions
* Each user can have multiple subscriptions (different devices/browsers) * Each user can have multiple subscriptions (different devices/browsers)
*/ */
export function createPushSubscriptionsCollection(access: NotificationAccess = {}, options: NotificationsPluginOptions = {}): CollectionConfig { export function createPushSubscriptionsCollection(options: NotificationsPluginOptions): CollectionConfig {
const defaultAccess: NotificationAccess = { const access: CollectionConfig['access'] = {
read: ({ req }: { req: any }) => Boolean(req.user), read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user), create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user), update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user), delete: ({ req }: { req: any }) => Boolean(req.user),
} }
return { const config: CollectionConfig = {
slug: 'push-subscriptions', slug: 'push-subscriptions',
labels: { labels: {
singular: 'Push Subscription', singular: 'Push Subscription',
@@ -76,16 +76,11 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = {
name: 'channels', name: 'channels',
type: 'select', type: 'select',
label: 'Subscribed Channels', label: 'Subscribed Channels',
options: options.channels && options.channels.length > 0 options: options.channels.map(channel => ({
? options.channels.map(channel => ({ label: channel.name,
label: channel.name, value: channel.id,
value: channel.id, })),
}))
: [{ label: 'All Notifications', value: 'all' }],
hasMany: true, hasMany: true,
defaultValue: options.channels && options.channels.length > 0
? options.channels.filter(channel => channel.defaultEnabled !== false).map(channel => channel.id)
: ['all'],
admin: { admin: {
description: 'Channels this subscription is subscribed to - leave empty for all notifications', description: 'Channels this subscription is subscribed to - leave empty for all notifications',
}, },
@@ -101,12 +96,7 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = {
}, },
}, },
], ],
access: { access,
read: access.read || defaultAccess.read!,
create: access.create || defaultAccess.create!,
update: access.update || defaultAccess.update!,
delete: access.delete || defaultAccess.delete!,
},
timestamps: true, timestamps: true,
hooks: { hooks: {
beforeChange: [ beforeChange: [
@@ -120,4 +110,7 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = {
], ],
}, },
} }
return options.collectionOverrides?.pushSubscriptions ?
options.collectionOverrides.pushSubscriptions(config) :
config
} }

View File

@@ -24,20 +24,19 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
} }
const body = await req.json?.() const body = await req.json?.()
if (!body) { if (!body || !(typeof body === 'object' && 'subscription' in body && 'userAgent' in body && 'channels' in body)) {
return Response.json({ error: 'Invalid request body' }, { status: 400 }) return Response.json({ error: 'Invalid request body' }, { status: 400 })
} }
const { subscription, userAgent, channels } = body const { subscription, userAgent, channels } = body as { subscription: any, userAgent: string, channels: string[] }
if (!subscription || !subscription.endpoint) { if (!subscription || !subscription.endpoint) {
return Response.json({ error: 'Invalid subscription data' }, { status: 400 }) return Response.json({ error: 'Invalid subscription data' }, { status: 400 })
} }
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
@@ -61,11 +60,11 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
handler: async (req: PayloadRequest) => { handler: async (req: PayloadRequest) => {
try { try {
const body = await req.json?.() const body = await req.json?.()
if (!body) { if (!body || !(typeof body === 'object' && 'endpoint' in body)) {
return Response.json({ error: 'Invalid request body' }, { status: 400 }) return Response.json({ error: 'Invalid request body' }, { status: 400 })
} }
const { endpoint } = body const { endpoint } = body as { endpoint: string }
if (!endpoint) { if (!endpoint) {
return Response.json({ error: 'Endpoint is required' }, { status: 400 }) return Response.json({ error: 'Endpoint is required' }, { status: 400 })
@@ -103,148 +102,5 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
} }
}, },
}, },
// 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 }
)
}
},
},
] ]
} }

View File

@@ -143,7 +143,7 @@ export function usePushNotifications(vapidPublicKey: string) {
const [isSupported, setIsSupported] = ReactHooks.useState(false) const [isSupported, setIsSupported] = ReactHooks.useState(false)
const [isSubscribed, setIsSubscribed] = ReactHooks.useState(false) const [isSubscribed, setIsSubscribed] = ReactHooks.useState(false)
const [permission, setPermission] = ReactHooks.useState('default' as NotificationPermission) const [permission, setPermission] = ReactHooks.useState('default')
const [pushManager, setPushManager] = ReactHooks.useState(null) const [pushManager, setPushManager] = ReactHooks.useState(null)
ReactHooks.useEffect(() => { ReactHooks.useEffect(() => {

View File

@@ -12,7 +12,4 @@ export type {
WebPushConfig, WebPushConfig,
PushSubscription, PushSubscription,
NotificationsPluginOptions, NotificationsPluginOptions,
NotificationRelationship,
NotificationCollectionConfig,
NotificationAccess,
} from '../types' } from '../types'

View File

@@ -4,6 +4,16 @@ import { createNotificationsCollection } from './collections/notifications'
import { createPushSubscriptionsCollection } from './collections/push-subscriptions' import { createPushSubscriptionsCollection } from './collections/push-subscriptions'
import { createPushNotificationEndpoints } from './endpoints/push-notifications' import { createPushNotificationEndpoints } from './endpoints/push-notifications'
const defaultOptions: NotificationsPluginOptions = {
channels: [
{
name: 'Default',
id: 'default',
description: 'Default channel',
}
]
}
/** /**
* PayloadCMS Notifications Plugin * PayloadCMS Notifications Plugin
* *
@@ -16,7 +26,7 @@ import { createPushNotificationEndpoints } from './endpoints/push-notifications'
* @param options Plugin configuration options * @param options Plugin configuration options
* @returns Configured PayloadCMS plugin * @returns Configured PayloadCMS plugin
*/ */
export const notificationsPlugin: NotificationsPlugin = (options = {}) => { export const notificationsPlugin: NotificationsPlugin = (options = defaultOptions) => {
return (config: Config): Config => { return (config: Config): Config => {
// Create the notifications collection with provided options // Create the notifications collection with provided options
const notificationsCollection = createNotificationsCollection(options) const notificationsCollection = createNotificationsCollection(options)
@@ -30,7 +40,7 @@ export const notificationsPlugin: NotificationsPlugin = (options = {}) => {
// Add push subscriptions collection if web push is enabled // Add push subscriptions collection if web push is enabled
if (options.webPush?.enabled) { if (options.webPush?.enabled) {
const pushSubscriptionsCollection = createPushSubscriptionsCollection(options.access, options) const pushSubscriptionsCollection = createPushSubscriptionsCollection(options)
newCollections.push(pushSubscriptionsCollection) newCollections.push(pushSubscriptionsCollection)
} }
@@ -54,9 +64,6 @@ export const notificationsPlugin: NotificationsPlugin = (options = {}) => {
// Export types for consumers // Export types for consumers
export type { export type {
NotificationsPluginOptions, NotificationsPluginOptions,
NotificationRelationship,
NotificationCollectionConfig,
NotificationAccess,
NotificationChannel, NotificationChannel,
WebPushConfig, WebPushConfig,
} from './types' } from './types'

View File

@@ -1,44 +1,5 @@
import type { Config, CollectionConfig, Access, Field } from 'payload' import type {CollectionConfig, Config} from 'payload'
import type * as webpush from 'web-push' import type {RequestOptions} 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 * Web push subscription data structure
@@ -61,8 +22,6 @@ export interface NotificationChannel {
name: string name: string
/** Channel description */ /** Channel description */
description?: string description?: string
/** Default enabled state for new subscriptions */
defaultEnabled?: boolean
} }
/** /**
@@ -78,7 +37,7 @@ export interface WebPushConfig {
/** Enable web push notifications */ /** Enable web push notifications */
enabled?: boolean enabled?: boolean
/** Custom push notification options */ /** Custom push notification options */
options?: webpush.RequestOptions options?: RequestOptions
/** Automatically send push notifications when notifications are created */ /** Automatically send push notifications when notifications are created */
autoPush?: boolean autoPush?: boolean
/** Custom notification content transformer */ /** Custom notification content transformer */
@@ -106,17 +65,14 @@ export interface WebPushConfig {
*/ */
export interface NotificationsPluginOptions { export interface NotificationsPluginOptions {
/** Collection configuration */ /** Collection configuration */
collections?: NotificationCollectionConfig collectionOverrides?: {
/** Array of configurable relationship fields */ notifications: (config: CollectionConfig) => CollectionConfig
relationships?: NotificationRelationship[] pushSubscriptions: (config: CollectionConfig) => CollectionConfig
/** Custom access control functions */ }
access?: NotificationAccess
/** Additional custom fields to add to the collection */
fields?: Field[]
/** Web push notification configuration */ /** Web push notification configuration */
webPush?: WebPushConfig webPush?: WebPushConfig
/** Notification channels configuration */ /** Notification channels configuration */
channels?: NotificationChannel[] channels: NotificationChannel[]
} }
/** /**

View File

@@ -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,
},
]
}

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[]
@@ -211,7 +211,7 @@ export class WebPushManager {
p256dh: subscription.keys.p256dh, p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth, auth: subscription.keys.auth,
userAgent, userAgent,
channels: channels || ['all'], channels,
isActive: true, isActive: true,
}, },
}) })
@@ -225,7 +225,7 @@ export class WebPushManager {
p256dh: subscription.keys.p256dh, p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth, auth: subscription.keys.auth,
userAgent, userAgent,
channels: channels || ['all'], channels,
isActive: true, isActive: true,
}, },
}) })

View File

@@ -1,26 +1,35 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowSyntheticDefaultImports": true, "baseUrl": ".",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022", "DOM", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022", "target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable",
"WebWorker"
],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": false,
"outDir": "./dist", "strict": true,
"rootDir": "./src" "esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
}, },
"include": [ "include": [
"./src/**/*.ts", "src/**/*"
"./src/**/*.tsx",
"./dev/next-env.d.ts"
], ],
"exclude": ["node_modules", "dist"] "exclude": [
"src/**/*.test.ts",
"src/**/*.spec.ts",
"dev",
"node_modules",
"dist"
]
} }