mirror of
https://github.com/xtr-dev/payload-notifications.git
synced 2025-12-10 19:03:23 +00:00
Compare commits
12 Commits
add-claude
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| baa6af990c | |||
| 3425ec92dc | |||
| f5cf5dfea9 | |||
| 55fe0418f9 | |||
| bc3b12de49 | |||
| c0e2177d71 | |||
| 9f78d3ef72 | |||
| bb963a4da4 | |||
| 672be644cf | |||
| 553ccea7fa | |||
| 764462302e | |||
|
|
bcf11194eb |
43
.github/workflows/pr-version-check.yml
vendored
Normal file
43
.github/workflows/pr-version-check.yml
vendored
Normal 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
|
||||
49
.github/workflows/version-and-publish.yml
vendored
Normal file
49
.github/workflows/version-and-publish.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -47,3 +47,4 @@ yarn-error.log*
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/dev.db
|
||||
|
||||
385
README.md
385
README.md
@@ -1,5 +1,7 @@
|
||||
# @xtr-dev/payload-notifications
|
||||
|
||||
[](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.
|
||||
|
||||
⚠️ **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.
|
||||
@@ -8,13 +10,11 @@ A PayloadCMS plugin that adds a configurable notifications collection for sendin
|
||||
|
||||
- 📧 Notifications collection with title and message fields
|
||||
- 🔗 Configurable relationship attachments to any collection
|
||||
- 📱 Built-in read/unread status tracking
|
||||
- 📱 Built-in read/unread status tracking
|
||||
- 🎯 Recipient targeting support
|
||||
- ⚙️ Flexible plugin configuration
|
||||
- 📅 Automatic timestamp tracking
|
||||
- 🔔 **Web Push Notifications** for mobile PWA support
|
||||
- 📲 Service Worker integration for offline notifications
|
||||
- 🔐 VAPID keys support for secure push messaging
|
||||
- 🔔 Optional web push notifications support (see [WEBPUSH.md](./WEBPUSH.md))
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -52,7 +52,7 @@ notificationsPlugin({
|
||||
})
|
||||
```
|
||||
|
||||
### Advanced Configuration with Relationships and Web Push
|
||||
### Advanced Configuration with Relationships
|
||||
|
||||
```typescript
|
||||
notificationsPlugin({
|
||||
@@ -71,7 +71,7 @@ notificationsPlugin({
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
relationTo: 'users',
|
||||
relationTo: 'users',
|
||||
label: 'Related User'
|
||||
},
|
||||
{
|
||||
@@ -87,37 +87,14 @@ notificationsPlugin({
|
||||
update: ({ req }) => Boolean(req.user?.role === 'admin'),
|
||||
delete: ({ req }) => Boolean(req.user?.role === 'admin'),
|
||||
},
|
||||
webPush: {
|
||||
enabled: true,
|
||||
autoPush: true, // Automatically send push notifications when notifications are created
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
|
||||
vapidSubject: 'mailto:your-email@example.com',
|
||||
// Optional: Custom notification transformer
|
||||
transformNotification: (notification) => ({
|
||||
title: `🔔 ${notification.title}`,
|
||||
body: extractTextFromRichText(notification.message).substring(0, 120) + '...',
|
||||
icon: '/icons/notification-icon.png',
|
||||
badge: '/icons/notification-badge.png',
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
url: `/admin/collections/notifications/${notification.id}`
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'View', icon: '/icons/view.png' },
|
||||
{ action: 'dismiss', title: 'Dismiss' }
|
||||
]
|
||||
}),
|
||||
// Optional: Custom hook for finding push subscriptions (for anonymous notifications)
|
||||
findSubscriptions: async (notification, payload) => {
|
||||
// Custom logic to find subscriptions based on notification data
|
||||
// Return array of push subscription documents
|
||||
return []
|
||||
}
|
||||
}
|
||||
fields: [
|
||||
// Add custom fields to the notifications collection
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
> For web push notifications setup, see [WEBPUSH.md](./WEBPUSH.md)
|
||||
|
||||
## Collection Schema
|
||||
|
||||
The plugin creates a notifications collection with the following fields:
|
||||
@@ -231,282 +208,110 @@ notificationsPlugin({
|
||||
})
|
||||
```
|
||||
|
||||
## Web Push Notifications
|
||||
## Email Notifications
|
||||
|
||||
The plugin supports web push notifications for PWA and mobile browser users.
|
||||
You can add email functionality to notifications using the `collectionOverrides` option. This allows you to add custom hooks to the notifications collection without modifying the plugin code.
|
||||
|
||||
### Anonymous Notifications Support
|
||||
### Using Collection Overrides
|
||||
|
||||
For scenarios where you need to send notifications to anonymous users or have custom recipient logic (e.g., notifications based on email addresses, phone numbers, or custom identifiers), you can use the `findSubscriptions` hook combined with custom fields.
|
||||
|
||||
**Example: Email-based notifications**
|
||||
The key is to preserve existing hooks (like web push) while adding your own:
|
||||
|
||||
```typescript
|
||||
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
|
||||
|
||||
notificationsPlugin({
|
||||
// Add custom email field to notifications collection
|
||||
fields: [
|
||||
{
|
||||
name: 'recipientEmail',
|
||||
type: 'email',
|
||||
label: 'Recipient Email',
|
||||
admin: {
|
||||
description: 'Email address of the notification recipient',
|
||||
},
|
||||
}
|
||||
],
|
||||
webPush: {
|
||||
enabled: true,
|
||||
autoPush: true,
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
|
||||
vapidSubject: 'mailto:your-email@example.com',
|
||||
// Custom hook to find subscriptions based on email
|
||||
findSubscriptions: async (notification, payload) => {
|
||||
if (!notification.recipientEmail) return []
|
||||
|
||||
// Find push subscriptions associated with this email
|
||||
const subscriptions = await payload.find({
|
||||
collection: 'push-subscriptions',
|
||||
where: {
|
||||
and: [
|
||||
{ recipientEmail: { equals: notification.recipientEmail } },
|
||||
{ isActive: { equals: true } },
|
||||
// Channel filtering (if specified)
|
||||
...(notification.channel ? [{
|
||||
or: [
|
||||
{ channels: { contains: notification.channel } },
|
||||
{ channels: { contains: 'all' } },
|
||||
{ channels: { exists: false } },
|
||||
]
|
||||
}] : [])
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return subscriptions.docs
|
||||
}
|
||||
channels: [{ id: 'default', name: 'Default' }],
|
||||
collectionOverrides: {
|
||||
notifications: (config) => ({
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks, // Preserve existing hooks (web push, etc.)
|
||||
afterChange: [
|
||||
...(config.hooks?.afterChange || []), // Preserve existing afterChange hooks
|
||||
// Add your custom email hook
|
||||
async ({ doc, operation, req }) => {
|
||||
if (operation === 'create') {
|
||||
// Your email logic here
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Example: Phone number-based notifications**
|
||||
### Example: Custom Email Service
|
||||
|
||||
```typescript
|
||||
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
|
||||
import { sendEmail } from './your-email-service'
|
||||
import { renderNotificationEmail } from './email-templates'
|
||||
|
||||
notificationsPlugin({
|
||||
fields: [
|
||||
{
|
||||
name: 'recipientPhone',
|
||||
type: 'text',
|
||||
label: 'Recipient Phone',
|
||||
admin: {
|
||||
description: 'Phone number of the notification recipient',
|
||||
},
|
||||
}
|
||||
],
|
||||
webPush: {
|
||||
enabled: true,
|
||||
autoPush: true,
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
|
||||
vapidSubject: 'mailto:your-email@example.com',
|
||||
findSubscriptions: async (notification, payload) => {
|
||||
if (!notification.recipientPhone) return []
|
||||
|
||||
// Custom logic to find subscriptions by phone number
|
||||
// You might have a separate mapping table or user lookup
|
||||
const user = await payload.find({
|
||||
collection: 'users',
|
||||
where: { phone: { equals: notification.recipientPhone } },
|
||||
limit: 1
|
||||
})
|
||||
|
||||
if (!user.docs[0]) return []
|
||||
|
||||
const subscriptions = await payload.find({
|
||||
collection: 'push-subscriptions',
|
||||
where: {
|
||||
and: [
|
||||
{ user: { equals: user.docs[0].id } },
|
||||
{ isActive: { equals: true } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return subscriptions.docs
|
||||
}
|
||||
channels: [{ id: 'default', name: 'Default' }],
|
||||
collectionOverrides: {
|
||||
notifications: (config) => ({
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks,
|
||||
afterChange: [
|
||||
...(config.hooks?.afterChange || []),
|
||||
async ({ doc, operation, req }) => {
|
||||
// Send email when notification is created
|
||||
if (operation === 'create') {
|
||||
try {
|
||||
// Get recipient user details
|
||||
let recipientId = doc.recipient
|
||||
if (typeof recipientId === 'object' && recipientId?.id) {
|
||||
recipientId = recipientId.id
|
||||
}
|
||||
|
||||
if (!recipientId) {
|
||||
console.log('No recipient for email notification')
|
||||
return
|
||||
}
|
||||
|
||||
const recipient = await req.payload.findByID({
|
||||
collection: 'users',
|
||||
id: recipientId
|
||||
})
|
||||
|
||||
if (!recipient?.email) {
|
||||
console.log('Recipient has no email address')
|
||||
return
|
||||
}
|
||||
|
||||
// Send email
|
||||
await sendEmail({
|
||||
to: recipient.email,
|
||||
subject: doc.title,
|
||||
html: renderNotificationEmail(doc)
|
||||
})
|
||||
|
||||
console.log(`Email sent to ${recipient.email}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification email:', error)
|
||||
// Don't throw - we don't want to prevent notification creation
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- The default `recipient` field remains a user relationship for standard notifications
|
||||
- Add custom recipient fields via the `fields` option for your specific use case
|
||||
- Use the `findSubscriptions` hook to implement custom subscription lookup logic
|
||||
- The hook receives the full notification document and payload instance
|
||||
- Return an array of push subscription documents that should receive the notification
|
||||
- The plugin will handle the actual push notification sending and error handling
|
||||
|
||||
### Setup VAPID Keys
|
||||
|
||||
**Step 1:** Generate VAPID keys for secure push messaging:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
This will output something like:
|
||||
```
|
||||
=======================================
|
||||
Public Key:
|
||||
BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A
|
||||
|
||||
Private Key:
|
||||
RVtnLcW8qlSkuhNskz8lwBwYcam78x-zO0Ssm_P2bmE
|
||||
=======================================
|
||||
```
|
||||
|
||||
**Step 2:** Add the keys to your environment variables:
|
||||
|
||||
```env
|
||||
VAPID_PUBLIC_KEY=BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A
|
||||
VAPID_PRIVATE_KEY=RVtnLcW8qlSkuhNskz8lwBwYcam78x-zO0Ssm_P2bmE
|
||||
```
|
||||
|
||||
**Step 3:** Restart your application to load the new environment variables.
|
||||
|
||||
⚠️ **Important:** Keep your private key secure and never commit it to version control!
|
||||
|
||||
### Client-Side Integration
|
||||
|
||||
⚠️ **Authentication Required:** Users must be signed in to subscribe to push notifications. Push subscriptions are associated with user accounts.
|
||||
|
||||
```typescript
|
||||
import { ClientPushManager, usePushNotifications } from '@xtr-dev/payload-notifications/client'
|
||||
|
||||
// React Hook (if using React)
|
||||
function NotificationSettings() {
|
||||
const {
|
||||
isSupported,
|
||||
isSubscribed,
|
||||
permission,
|
||||
subscribe,
|
||||
unsubscribe
|
||||
} = usePushNotifications(process.env.NEXT_PUBLIC_VAPID_KEY)
|
||||
|
||||
if (!isSupported) return <div>Push notifications not supported</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Status: {isSubscribed ? 'Subscribed' : 'Not subscribed'}</p>
|
||||
<p>Permission: {permission}</p>
|
||||
|
||||
{!isSubscribed ? (
|
||||
<button onClick={subscribe}>Enable Notifications</button>
|
||||
) : (
|
||||
<button onClick={unsubscribe}>Disable Notifications</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vanilla JavaScript
|
||||
const pushManager = new ClientPushManager('your-vapid-public-key')
|
||||
|
||||
// Subscribe to notifications
|
||||
await pushManager.subscribe()
|
||||
|
||||
// Check subscription status
|
||||
const isSubscribed = await pushManager.isSubscribed()
|
||||
```
|
||||
|
||||
### Service Worker Setup
|
||||
|
||||
Generate a service worker file automatically:
|
||||
|
||||
```bash
|
||||
npx @xtr-dev/payload-notifications generate-sw
|
||||
```
|
||||
|
||||
This will create a `/public/sw.js` file with the complete service worker template that handles:
|
||||
|
||||
- Push notification events
|
||||
- Notification click handling
|
||||
- Service worker lifecycle management
|
||||
- Error handling and fallbacks
|
||||
- Notification tracking and analytics
|
||||
|
||||
**Important Notes:**
|
||||
- The service worker file **must** be placed at `/public/sw.js` in Next.js projects
|
||||
- This makes it accessible at `https://yourdomain.com/sw.js`
|
||||
- Service workers must be served from the root domain for security
|
||||
- After creating the file, restart your Next.js development server
|
||||
- Always spread existing hooks (`...config.hooks`) to preserve plugin functionality
|
||||
- Use the spread operator for hook arrays (`...(config.hooks?.afterChange || [])`)
|
||||
- Don't throw errors in hooks if you want to allow notification creation to succeed even if email fails
|
||||
- Email sending happens asynchronously after the notification is created
|
||||
|
||||
### Server-Side Push Notifications
|
||||
## Web Push Notifications
|
||||
|
||||
```typescript
|
||||
import { WebPushManager } from '@xtr-dev/payload-notifications/rsc'
|
||||
|
||||
// Send push notification to a user
|
||||
const pushManager = new WebPushManager(webPushConfig, payload)
|
||||
|
||||
await pushManager.sendToUser(
|
||||
userId,
|
||||
'Order Shipped!',
|
||||
'Your order #12345 has been shipped',
|
||||
{
|
||||
icon: '/icons/order-shipped.png',
|
||||
badge: '/icons/badge.png',
|
||||
data: { orderId: '12345', url: '/orders/12345' },
|
||||
actions: [
|
||||
{ action: 'view', title: 'View Order', icon: '/icons/view.png' },
|
||||
{ action: 'dismiss', title: 'Dismiss' }
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The plugin automatically creates these endpoints when web push is enabled:
|
||||
|
||||
- `POST /api/push-notifications/subscribe` - Subscribe to push notifications ⚠️ **Requires authentication**
|
||||
- `POST /api/push-notifications/unsubscribe` - Unsubscribe from push notifications
|
||||
- `GET /api/push-notifications/vapid-public-key` - Get VAPID public key
|
||||
- `POST /api/push-notifications/send` - Send notification to user ⚠️ **Requires authentication**
|
||||
- `POST /api/push-notifications/test` - Send test notification ⚠️ **Admin only**
|
||||
- `POST /api/push-notifications/track` - Track notification events
|
||||
|
||||
### Integration with Notifications Collection
|
||||
|
||||
When creating notifications, you can automatically send push notifications:
|
||||
|
||||
```typescript
|
||||
// Create notification and send push notification
|
||||
const notification = await payload.create({
|
||||
collection: 'notifications',
|
||||
data: {
|
||||
title: 'New Message',
|
||||
message: [{ children: [{ text: 'You have a new message!' }] }],
|
||||
recipient: userId,
|
||||
attachments: { message: messageId }
|
||||
}
|
||||
})
|
||||
|
||||
// Send push notification
|
||||
if (webPushEnabled) {
|
||||
await pushManager.sendToUser(
|
||||
userId,
|
||||
notification.title,
|
||||
'You have a new notification',
|
||||
{
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
url: `/notifications/${notification.id}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
The plugin includes optional web push notifications support for PWA and mobile browser users. For complete setup instructions, configuration options, and usage examples, see [WEBPUSH.md](./WEBPUSH.md).
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
|
||||
348
WEBPUSH.md
Normal file
348
WEBPUSH.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Web Push Notifications
|
||||
|
||||
The `@xtr-dev/payload-notifications` plugin includes built-in support for web push notifications for PWA and mobile browser users.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔔 Web Push Notifications for mobile PWA support
|
||||
- 📲 Service Worker integration for offline notifications
|
||||
- 🔐 VAPID keys support for secure push messaging
|
||||
- 🎯 Auto-push on notification creation
|
||||
- 🔄 Custom notification transformers
|
||||
- 👤 Anonymous user notification support
|
||||
|
||||
## Setup VAPID Keys
|
||||
|
||||
**Step 1:** Generate VAPID keys for secure push messaging:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
This will output something like:
|
||||
```
|
||||
=======================================
|
||||
Public Key:
|
||||
BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A
|
||||
|
||||
Private Key:
|
||||
RVtnLcW8qlSkuhNskz8lwBwYcam78x-zO0Ssm_P2bmE
|
||||
=======================================
|
||||
```
|
||||
|
||||
**Step 2:** Add the keys to your environment variables:
|
||||
|
||||
```env
|
||||
VAPID_PUBLIC_KEY=BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A
|
||||
VAPID_PRIVATE_KEY=RVtnLcW8qlSkuhNskz8lwBwYcam78x-zO0Ssm_P2bmE
|
||||
```
|
||||
|
||||
**Step 3:** Restart your application to load the new environment variables.
|
||||
|
||||
⚠️ **Important:** Keep your private key secure and never commit it to version control!
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
### Basic Web Push Setup
|
||||
|
||||
```typescript
|
||||
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
|
||||
|
||||
notificationsPlugin({
|
||||
webPush: {
|
||||
enabled: true,
|
||||
autoPush: true, // Automatically send push notifications when notifications are created
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
|
||||
vapidSubject: 'mailto:your-email@example.com',
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```typescript
|
||||
notificationsPlugin({
|
||||
webPush: {
|
||||
enabled: true,
|
||||
autoPush: true,
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
|
||||
vapidSubject: 'mailto:your-email@example.com',
|
||||
// Optional: Custom notification transformer
|
||||
transformNotification: (notification) => ({
|
||||
title: `🔔 ${notification.title}`,
|
||||
body: extractTextFromRichText(notification.message).substring(0, 120) + '...',
|
||||
icon: '/icons/notification-icon.png',
|
||||
badge: '/icons/notification-badge.png',
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
url: `/admin/collections/notifications/${notification.id}`
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'View', icon: '/icons/view.png' },
|
||||
{ action: 'dismiss', title: 'Dismiss' }
|
||||
]
|
||||
}),
|
||||
// Optional: Custom hook for finding push subscriptions (for anonymous notifications)
|
||||
findSubscriptions: async (notification, payload) => {
|
||||
// Custom logic to find subscriptions based on notification data
|
||||
// Return array of push subscription documents
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Anonymous Notifications Support
|
||||
|
||||
For scenarios where you need to send notifications to anonymous users or have custom recipient logic (e.g., notifications based on email addresses, phone numbers, or custom identifiers), you can use the `findSubscriptions` hook combined with custom fields.
|
||||
|
||||
### Example: Email-based notifications
|
||||
|
||||
```typescript
|
||||
notificationsPlugin({
|
||||
// Add custom email field to notifications collection
|
||||
fields: [
|
||||
{
|
||||
name: 'recipientEmail',
|
||||
type: 'email',
|
||||
label: 'Recipient Email',
|
||||
admin: {
|
||||
description: 'Email address of the notification recipient',
|
||||
},
|
||||
}
|
||||
],
|
||||
webPush: {
|
||||
enabled: true,
|
||||
autoPush: true,
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
|
||||
vapidSubject: 'mailto:your-email@example.com',
|
||||
// Custom hook to find subscriptions based on email
|
||||
findSubscriptions: async (notification, payload) => {
|
||||
if (!notification.recipientEmail) return []
|
||||
|
||||
// Find push subscriptions associated with this email
|
||||
const subscriptions = await payload.find({
|
||||
collection: 'push-subscriptions',
|
||||
where: {
|
||||
and: [
|
||||
{ recipientEmail: { equals: notification.recipientEmail } },
|
||||
{ isActive: { equals: true } },
|
||||
// Channel filtering (if specified)
|
||||
...(notification.channel ? [{
|
||||
or: [
|
||||
{ channels: { contains: notification.channel } },
|
||||
{ channels: { contains: 'all' } },
|
||||
{ channels: { exists: false } },
|
||||
]
|
||||
}] : [])
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return subscriptions.docs
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Example: Phone number-based notifications
|
||||
|
||||
```typescript
|
||||
notificationsPlugin({
|
||||
fields: [
|
||||
{
|
||||
name: 'recipientPhone',
|
||||
type: 'text',
|
||||
label: 'Recipient Phone',
|
||||
admin: {
|
||||
description: 'Phone number of the notification recipient',
|
||||
},
|
||||
}
|
||||
],
|
||||
webPush: {
|
||||
enabled: true,
|
||||
autoPush: true,
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
|
||||
vapidSubject: 'mailto:your-email@example.com',
|
||||
findSubscriptions: async (notification, payload) => {
|
||||
if (!notification.recipientPhone) return []
|
||||
|
||||
// Custom logic to find subscriptions by phone number
|
||||
// You might have a separate mapping table or user lookup
|
||||
const user = await payload.find({
|
||||
collection: 'users',
|
||||
where: { phone: { equals: notification.recipientPhone } },
|
||||
limit: 1
|
||||
})
|
||||
|
||||
if (!user.docs[0]) return []
|
||||
|
||||
const subscriptions = await payload.find({
|
||||
collection: 'push-subscriptions',
|
||||
where: {
|
||||
and: [
|
||||
{ user: { equals: user.docs[0].id } },
|
||||
{ isActive: { equals: true } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return subscriptions.docs
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- The default `recipient` field remains a user relationship for standard notifications
|
||||
- Add custom recipient fields via the `fields` option for your specific use case
|
||||
- Use the `findSubscriptions` hook to implement custom subscription lookup logic
|
||||
- The hook receives the full notification document and payload instance
|
||||
- Return an array of push subscription documents that should receive the notification
|
||||
- The plugin will handle the actual push notification sending and error handling
|
||||
|
||||
## Client-Side Integration
|
||||
|
||||
⚠️ **Authentication Required:** Users must be signed in to subscribe to push notifications. Push subscriptions are associated with user accounts.
|
||||
|
||||
### React Hook
|
||||
|
||||
```typescript
|
||||
import { usePushNotifications } from '@xtr-dev/payload-notifications/client'
|
||||
|
||||
function NotificationSettings() {
|
||||
const {
|
||||
isSupported,
|
||||
isSubscribed,
|
||||
permission,
|
||||
subscribe,
|
||||
unsubscribe
|
||||
} = usePushNotifications(process.env.NEXT_PUBLIC_VAPID_KEY)
|
||||
|
||||
if (!isSupported) return <div>Push notifications not supported</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Status: {isSubscribed ? 'Subscribed' : 'Not subscribed'}</p>
|
||||
<p>Permission: {permission}</p>
|
||||
|
||||
{!isSubscribed ? (
|
||||
<button onClick={subscribe}>Enable Notifications</button>
|
||||
) : (
|
||||
<button onClick={unsubscribe}>Disable Notifications</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Vanilla JavaScript
|
||||
|
||||
```typescript
|
||||
import { ClientPushManager } from '@xtr-dev/payload-notifications/client'
|
||||
|
||||
const pushManager = new ClientPushManager('your-vapid-public-key')
|
||||
|
||||
// Subscribe to notifications
|
||||
await pushManager.subscribe()
|
||||
|
||||
// Check subscription status
|
||||
const isSubscribed = await pushManager.isSubscribed()
|
||||
|
||||
// Unsubscribe
|
||||
await pushManager.unsubscribe()
|
||||
```
|
||||
|
||||
## Service Worker Setup
|
||||
|
||||
Generate a service worker file automatically:
|
||||
|
||||
```bash
|
||||
npx @xtr-dev/payload-notifications generate-sw
|
||||
```
|
||||
|
||||
This will create a `/public/sw.js` file with the complete service worker template that handles:
|
||||
|
||||
- Push notification events
|
||||
- Notification click handling
|
||||
- Service worker lifecycle management
|
||||
- Error handling and fallbacks
|
||||
- Notification tracking and analytics
|
||||
|
||||
**Important Notes:**
|
||||
- The service worker file **must** be placed at `/public/sw.js` in Next.js projects
|
||||
- This makes it accessible at `https://yourdomain.com/sw.js`
|
||||
- Service workers must be served from the root domain for security
|
||||
- After creating the file, restart your Next.js development server
|
||||
|
||||
## Server-Side Push Notifications
|
||||
|
||||
```typescript
|
||||
import { WebPushManager } from '@xtr-dev/payload-notifications/rsc'
|
||||
|
||||
// Send push notification to a user
|
||||
const pushManager = new WebPushManager(webPushConfig, payload)
|
||||
|
||||
await pushManager.sendToUser(
|
||||
userId,
|
||||
'Order Shipped!',
|
||||
'Your order #12345 has been shipped',
|
||||
{
|
||||
icon: '/icons/order-shipped.png',
|
||||
badge: '/icons/badge.png',
|
||||
data: { orderId: '12345', url: '/orders/12345' },
|
||||
actions: [
|
||||
{ action: 'view', title: 'View Order', icon: '/icons/view.png' },
|
||||
{ action: 'dismiss', title: 'Dismiss' }
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The plugin automatically creates these endpoints when web push is enabled:
|
||||
|
||||
- `POST /api/push-notifications/subscribe` - Subscribe to push notifications ⚠️ **Requires authentication**
|
||||
- `POST /api/push-notifications/unsubscribe` - Unsubscribe from push notifications
|
||||
- `GET /api/push-notifications/vapid-public-key` - Get VAPID public key
|
||||
- `POST /api/push-notifications/send` - Send notification to user ⚠️ **Requires authentication**
|
||||
- `POST /api/push-notifications/test` - Send test notification ⚠️ **Admin only**
|
||||
- `POST /api/push-notifications/track` - Track notification events
|
||||
|
||||
## Integration with Notifications Collection
|
||||
|
||||
When creating notifications, you can automatically send push notifications:
|
||||
|
||||
```typescript
|
||||
// Create notification and send push notification
|
||||
const notification = await payload.create({
|
||||
collection: 'notifications',
|
||||
data: {
|
||||
title: 'New Message',
|
||||
message: [{ children: [{ text: 'You have a new message!' }] }],
|
||||
recipient: userId,
|
||||
attachments: { message: messageId }
|
||||
}
|
||||
})
|
||||
|
||||
// With autoPush enabled, push notifications are sent automatically
|
||||
// Or manually send push notification
|
||||
if (webPushEnabled) {
|
||||
await pushManager.sendToUser(
|
||||
userId,
|
||||
notification.title,
|
||||
'You have a new notification',
|
||||
{
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
url: `/notifications/${notification.id}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -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<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)
|
||||
}
|
||||
}
|
||||
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<NotificationPermission>('default')
|
||||
const [pushManager, setPushManager] = useState<DemoClientPushManager | null>(null)
|
||||
const [pushManager, setPushManager] = useState<ClientPushManager | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedChannels, setSelectedChannels] = useState<string[]>(
|
||||
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,31 +71,12 @@ 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',
|
||||
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')
|
||||
}
|
||||
setIsSubscribed(false)
|
||||
alert('Successfully unsubscribed from push notifications')
|
||||
} catch (error) {
|
||||
console.error('Failed to unsubscribe:', error)
|
||||
alert('Failed to unsubscribe from push notifications: ' + (error as Error).message)
|
||||
@@ -271,27 +84,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 (
|
||||
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>Payload Notifications Plugin Demo</h1>
|
||||
|
||||
|
||||
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
|
||||
<h2>🔔 Web Push Notifications</h2>
|
||||
|
||||
|
||||
{!isSupported ? (
|
||||
<div style={{ color: 'red' }}>
|
||||
❌ Push notifications are not supported in this browser
|
||||
@@ -300,7 +99,7 @@ export default function DemoPage() {
|
||||
<div>
|
||||
<p><strong>Status:</strong> {isSubscribed ? '✅ Subscribed' : '❌ Not subscribed'}</p>
|
||||
<p><strong>Permission:</strong> {permission}</p>
|
||||
|
||||
|
||||
{!isSubscribed && (
|
||||
<div style={{ marginTop: '1rem', marginBottom: '1rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem' }}>📢 Select Notification Channels</h3>
|
||||
@@ -309,11 +108,11 @@ export default function DemoPage() {
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{AVAILABLE_CHANNELS.map(channel => (
|
||||
<label
|
||||
key={channel.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
<label
|
||||
key={channel.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #e0e0e0',
|
||||
@@ -347,10 +146,10 @@ export default function DemoPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
{!isSubscribed ? (
|
||||
<button
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
disabled={loading || selectedChannels.length === 0}
|
||||
style={{
|
||||
@@ -367,7 +166,7 @@ export default function DemoPage() {
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
<button
|
||||
onClick={handleUnsubscribe}
|
||||
disabled={loading}
|
||||
style={{
|
||||
@@ -382,21 +181,6 @@ export default function DemoPage() {
|
||||
>
|
||||
{loading ? 'Unsubscribing...' : 'Disable Notifications'}
|
||||
</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>
|
||||
@@ -490,4 +274,4 @@ export default function DemoPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<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>;
|
||||
'push-subscriptions': PushSubscriptionsSelect<false> | PushSubscriptionsSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
@@ -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<T extends boolean = true> {
|
||||
loginAttempts?: 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
|
||||
* via the `definition` "notifications_select".
|
||||
@@ -459,15 +310,9 @@ export interface NotificationsSelect<T extends boolean = true> {
|
||||
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<T extends boolean = true> {
|
||||
p256dh?: T;
|
||||
auth?: T;
|
||||
userAgent?: T;
|
||||
channels?: T;
|
||||
isActive?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { testEmailAdapter } from './helpers/testEmailAdapter.ts'
|
||||
import { seed } from './seed.ts'
|
||||
import {seed} from "./seed"
|
||||
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
|
||||
import {sqliteAdapter} from "@payloadcms/db-sqlite"
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -16,161 +16,128 @@ if (!process.env.ROOT_DIR) {
|
||||
process.env.ROOT_DIR = dirname
|
||||
}
|
||||
|
||||
const buildConfigWithMemoryDB = async () => {
|
||||
if (!process.env.DATABASE_URI) {
|
||||
// Use a simple memory server instead of replica set for better stability
|
||||
const { MongoMemoryServer } = await import('mongodb-memory-server')
|
||||
const memoryDB = await MongoMemoryServer.create({
|
||||
instance: {
|
||||
dbName: 'payloadmemory',
|
||||
},
|
||||
})
|
||||
|
||||
process.env.DATABASE_URI = memoryDB.getUri()
|
||||
}
|
||||
|
||||
return buildConfig({
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
collections: [
|
||||
// Users collection with roles for authentication
|
||||
{
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
collections: [
|
||||
// Users collection with roles for authentication
|
||||
{
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
],
|
||||
db: mongooseAdapter({
|
||||
ensureIndexes: true,
|
||||
url: process.env.DATABASE_URI || '',
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
email: testEmailAdapter,
|
||||
onInit: async (payload) => {
|
||||
await seed(payload)
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
// Demo of the notifications plugin with relationships and channels
|
||||
notificationsPlugin({
|
||||
collections: {
|
||||
slug: 'notifications',
|
||||
labels: {
|
||||
singular: 'Notification',
|
||||
plural: 'Notifications'
|
||||
}
|
||||
],
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: process.env.DATABASE_URI || 'file:./dev.db',
|
||||
},
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
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: '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'),
|
||||
{
|
||||
id: 'orders',
|
||||
name: 'Order Updates',
|
||||
description: 'Order status changes and shipping notifications',
|
||||
},
|
||||
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'
|
||||
{
|
||||
id: 'products',
|
||||
name: 'Product Updates',
|
||||
description: 'New products, restocks, and price changes',
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing & Promotions',
|
||||
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
|
||||
let body = 'You have a new notification'
|
||||
if (notification.message && Array.isArray(notification.message)) {
|
||||
const textParts: string[] = []
|
||||
notification.message.forEach((block: any) => {
|
||||
if (block.children && Array.isArray(block.children)) {
|
||||
block.children.forEach((child: any) => {
|
||||
if (child.text) textParts.push(child.text)
|
||||
})
|
||||
}
|
||||
})
|
||||
if (textParts.length > 0) {
|
||||
body = textParts.join(' ').substring(0, 120) + (textParts.join(' ').length > 120 ? '...' : '')
|
||||
// Extract text from rich text message
|
||||
let body = 'You have a new notification'
|
||||
if (notification.message && Array.isArray(notification.message)) {
|
||||
const textParts: string[] = []
|
||||
notification.message.forEach((block: any) => {
|
||||
if (block.children && Array.isArray(block.children)) {
|
||||
block.children.forEach((child: any) => {
|
||||
if (child.text) textParts.push(child.text)
|
||||
})
|
||||
}
|
||||
})
|
||||
if (textParts.length > 0) {
|
||||
body = textParts.join(' ').substring(0, 120) + (textParts.join(' ').length > 120 ? '...' : '')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `🔔 ${title}`,
|
||||
body,
|
||||
icon: '/icons/notification-icon.png',
|
||||
badge: '/icons/notification-badge.png',
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
url: `/admin/collections/notifications/${notification.id}`,
|
||||
createdAt: notification.createdAt,
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'View in Admin', icon: '/icons/view.png' },
|
||||
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
|
||||
],
|
||||
tag: `notification-${notification.id}`,
|
||||
requireInteraction: false,
|
||||
}
|
||||
return {
|
||||
title: `🔔 ${title}`,
|
||||
body,
|
||||
icon: '/icons/notification-icon.png',
|
||||
badge: '/icons/notification-badge.png',
|
||||
data: {
|
||||
notificationId: notification.id,
|
||||
url: `/admin/collections/notifications/${notification.id}`,
|
||||
createdAt: notification.createdAt,
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'View in Admin', icon: '/icons/view.png' },
|
||||
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
|
||||
],
|
||||
tag: `notification-${notification.id}`,
|
||||
requireInteraction: false,
|
||||
}
|
||||
}
|
||||
}),
|
||||
],
|
||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||
sharp,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default buildConfigWithMemoryDB()
|
||||
}
|
||||
}),
|
||||
],
|
||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||
sharp,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./",
|
||||
"rootDir": "../",
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"./payload.config.ts"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-notifications",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.4",
|
||||
"description": "A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -31,7 +31,7 @@
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm build",
|
||||
"test": "pnpm test:int && pnpm test:e2e",
|
||||
"test": "echo \"No tests configured yet\"",
|
||||
"test:e2e": "playwright test",
|
||||
"test:int": "vitest"
|
||||
},
|
||||
|
||||
@@ -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<PushSubscriptionData> {
|
||||
public async subscribe(channels: string[]): Promise<PushSubscriptionData> {
|
||||
// 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<PushSubscriptionData | null> {
|
||||
public async getSubscription(): Promise<Omit<PushSubscriptionData, 'channels'> | 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<void> {
|
||||
private async sendSubscriptionToServer(subscription: PushSubscriptionData, channels: string[]): Promise<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
)
|
||||
@@ -213,9 +188,9 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
||||
return
|
||||
}
|
||||
|
||||
let recipientId: string
|
||||
|
||||
if (typeof doc.recipient === 'string') {
|
||||
let recipientId: string|number
|
||||
|
||||
if (typeof doc.recipient === 'string' || typeof doc.recipient === 'number') {
|
||||
recipientId = doc.recipient
|
||||
} else if (doc.recipient?.id) {
|
||||
recipientId = 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
|
||||
}
|
||||
return options.collectionOverrides?.notifications ?
|
||||
options.collectionOverrides.notifications(config) :
|
||||
config
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
|
||||
}
|
||||
|
||||
const webPushConfig = options.webPush
|
||||
|
||||
|
||||
return [
|
||||
// Subscribe endpoint
|
||||
{
|
||||
@@ -24,20 +24,19 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
const { subscription, userAgent, channels } = body
|
||||
const { subscription, userAgent, channels } = body as { subscription: any, userAgent: string, channels: string[] }
|
||||
|
||||
if (!subscription || !subscription.endpoint) {
|
||||
return Response.json({ error: 'Invalid subscription data' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pushManager = new WebPushManager(webPushConfig, req.payload)
|
||||
|
||||
await pushManager.subscribe(
|
||||
String(req.user.id),
|
||||
req.user.id,
|
||||
subscription,
|
||||
userAgent,
|
||||
channels
|
||||
@@ -61,11 +60,11 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
|
||||
handler: async (req: PayloadRequest) => {
|
||||
try {
|
||||
const body = await req.json?.()
|
||||
if (!body) {
|
||||
if (!body || !(typeof body === 'object' && 'endpoint' in body)) {
|
||||
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { endpoint } = body
|
||||
const { endpoint } = body as { endpoint: string }
|
||||
|
||||
if (!endpoint) {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function usePushNotifications(vapidPublicKey: string) {
|
||||
|
||||
const [isSupported, setIsSupported] = 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)
|
||||
|
||||
ReactHooks.useEffect(() => {
|
||||
@@ -187,4 +187,4 @@ export function usePushNotifications(vapidPublicKey: string) {
|
||||
requestPermission,
|
||||
pushManager,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,4 @@ export type {
|
||||
WebPushConfig,
|
||||
PushSubscription,
|
||||
NotificationsPluginOptions,
|
||||
NotificationRelationship,
|
||||
NotificationCollectionConfig,
|
||||
NotificationAccess,
|
||||
} from '../types'
|
||||
} from '../types'
|
||||
|
||||
31
src/index.ts
31
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
|
||||
export default notificationsPlugin
|
||||
|
||||
66
src/types.ts
66
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
|
||||
export type NotificationsPlugin = (options?: NotificationsPluginOptions) => (config: Config) => Config
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class WebPushManager {
|
||||
* Send push notification to all active subscriptions for a recipient
|
||||
*/
|
||||
public async sendToRecipient(
|
||||
recipientId: string,
|
||||
recipientId: string|number,
|
||||
title: string,
|
||||
body: string,
|
||||
options?: {
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export class WebPushManager {
|
||||
* Subscribe a user to push notifications
|
||||
*/
|
||||
public async subscribe(
|
||||
userId: string,
|
||||
userId: string | number,
|
||||
subscription: PushSubscription,
|
||||
userAgent?: string,
|
||||
channels?: string[]
|
||||
@@ -211,7 +211,7 @@ export class WebPushManager {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
userAgent,
|
||||
channels: channels || ['all'],
|
||||
channels,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
@@ -225,7 +225,7 @@ export class WebPushManager {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
userAgent,
|
||||
channels: channels || ['all'],
|
||||
channels,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
@@ -268,4 +268,4 @@ export class WebPushManager {
|
||||
public getVapidPublicKey(): string {
|
||||
return this.config.vapidPublicKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["ES2022", "DOM", "WebWorker"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"WebWorker"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"sourceMap": false,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx",
|
||||
"./dev/next-env.d.ts"
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"dev",
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user