Add initial plugin implementation and development setup

This commit is contained in:
2025-09-13 17:06:45 +02:00
parent 45fe248bf3
commit 74cef91401
52 changed files with 16645 additions and 0 deletions

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}

24
.swcrc Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

24
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}"
}
]
}

40
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,40 @@
{
"npm.packageManager": "pnpm",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"editor.formatOnSaveMode": "file",
"typescript.tsdk": "node_modules/typescript/lib",
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}

77
CLAUDE.md Normal file
View File

@@ -0,0 +1,77 @@
# @xtr-dev/payload-notifications Development Guide
## Project Overview
This is a PayloadCMS plugin that adds a configurable notifications collection. The plugin allows developers to:
- Create notifications with titles and rich text messages
- Configure relationship attachments to any collection
- Track read/unread status
- Target specific recipients
## Architecture
### Plugin Structure
```
src/
├── index.ts # Main plugin export
├── types.ts # TypeScript interfaces
├── collections/
│ └── notifications.ts # Notifications collection schema
└── utils/
└── buildFields.ts # Dynamic field builder for relationships
```
## Development Guidelines
### Code Style
- Use TypeScript for all files
- Follow PayloadCMS plugin conventions
- Use descriptive variable and function names
- Add JSDoc comments for public APIs
### Plugin Configuration
The plugin accepts a configuration object with:
- `collections`: Collection settings (slug, labels)
- `relationships`: Array of relationship configurations
- `access`: Custom access control functions
- `fields`: Additional custom fields
### Relationship System
- Relationships are stored in an `attachments` group field
- Each relationship is dynamically generated based on config
- Supports single and multiple selections (`hasMany`)
### Collection Schema
The notifications collection includes:
- Required fields: title, message, recipient
- Optional fields: isRead, readAt, attachments
- Automatic timestamps: createdAt, updatedAt
## Testing Strategy
- Test with different PayloadCMS versions
- Verify relationship configurations work correctly
- Test access control functionality
- Ensure TypeScript types are accurate
## Build Process
- Use TypeScript compiler for builds
- Generate declaration files (.d.ts)
- Bundle for both CommonJS and ES modules
- Include source maps for debugging
## Plugin Registration
The plugin should be registered in PayloadCMS using the standard plugin pattern:
```typescript
export const notificationsPlugin = (options: NotificationsPluginOptions = {}) => {
return (config: Config): Config => {
// Plugin implementation
}
}
```
## Key Implementation Notes
1. Use PayloadCMS field types and validation
2. Leverage PayloadCMS access control patterns
3. Generate relationship fields dynamically based on config
4. Provide sensible defaults for all configuration options
5. Ensure plugin doesn't conflict with existing collections

517
README.md Normal file
View File

@@ -0,0 +1,517 @@
# @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.
## Features
- 📧 Notifications collection with title and message fields
- 🔗 Configurable relationship attachments to any collection
- 📱 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
## Installation
```bash
npm install @xtr-dev/payload-notifications
```
## Basic Usage
Add the plugin to your Payload config:
```typescript
import { buildConfig } from 'payload/config'
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
export default buildConfig({
plugins: [
notificationsPlugin({
// Basic configuration
})
],
// ... rest of your config
})
```
## Configuration
### Basic Configuration
```typescript
notificationsPlugin({
collections: {
slug: 'notifications', // Default collection slug
}
})
```
### Advanced Configuration with Relationships and Web Push
```typescript
notificationsPlugin({
collections: {
slug: 'notifications',
labels: {
singular: 'Notification',
plural: 'Notifications'
}
},
relationships: [
{
name: 'order',
relationTo: 'orders',
label: 'Related Order'
},
{
name: 'user',
relationTo: 'users',
label: 'Related User'
},
{
name: 'product',
relationTo: 'products',
label: 'Related Product'
}
],
access: {
// Custom access control functions
read: ({ req }) => Boolean(req.user),
create: ({ req }) => Boolean(req.user?.role === 'admin'),
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 []
}
}
})
```
## Collection Schema
The plugin creates a notifications collection with the following fields:
- **title** (required text): The notification title
- **message** (required richText): The notification content
- **recipient** (optional relationship): User who should receive the notification (optional if using custom recipient fields)
- **isRead** (checkbox): Read status tracking
- **readAt** (date): When the notification was read
- **attachments** (group): Configurable relationship fields
- **createdAt/updatedAt**: Automatic timestamps
## API Usage
### Creating Notifications
```typescript
const notification = await payload.create({
collection: 'notifications',
data: {
title: 'Order Shipped',
message: [
{
children: [
{ text: 'Your order has been shipped and is on its way!' }
]
}
],
recipient: userId,
attachments: {
order: orderId,
product: productId
}
}
})
```
### Querying Notifications
```typescript
// Get unread notifications for a user
const unreadNotifications = await payload.find({
collection: 'notifications',
where: {
and: [
{ recipient: { equals: userId } },
{ isRead: { equals: false } }
]
},
sort: '-createdAt'
})
// Mark notification as read
await payload.update({
collection: 'notifications',
id: notificationId,
data: {
isRead: true,
readAt: new Date()
}
})
```
## Plugin Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `collections.slug` | `string` | `'notifications'` | Collection slug |
| `collections.labels` | `object` | `{ singular: 'Notification', plural: 'Notifications' }` | Collection labels |
| `relationships` | `array` | `[]` | Configurable relationship fields |
| `access` | `object` | Default access | Custom access control functions |
| `fields` | `array` | `[]` | Additional custom fields |
### Relationship Configuration
Each relationship in the `relationships` array supports:
```typescript
{
name: string; // Field name in attachments group
relationTo: string; // Target collection slug
label?: string; // Admin UI label
required?: boolean; // Whether field is required
hasMany?: boolean; // Allow multiple selections
}
```
## Examples
### E-commerce Notifications
```typescript
notificationsPlugin({
relationships: [
{ name: 'order', relationTo: 'orders', label: 'Order' },
{ name: 'product', relationTo: 'products', label: 'Product', hasMany: true },
{ name: 'customer', relationTo: 'customers', label: 'Customer' }
]
})
```
### Content Management Notifications
```typescript
notificationsPlugin({
relationships: [
{ name: 'post', relationTo: 'posts', label: 'Blog Post' },
{ name: 'page', relationTo: 'pages', label: 'Page' },
{ name: 'media', relationTo: 'media', label: 'Media', hasMany: true }
]
})
```
## Web Push Notifications
The plugin supports web push notifications for PWA and mobile browser users.
### 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
### 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
### 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 }
}
})
// Send push notification
if (webPushEnabled) {
await pushManager.sendToUser(
userId,
notification.title,
'You have a new notification',
{
data: {
notificationId: notification.id,
url: `/notifications/${notification.id}`
}
}
)
}
```
## TypeScript Support
The plugin includes full TypeScript support. Types are automatically generated based on your configuration.
## License
MIT

13
dev/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Database URL - will use in-memory DB for development if not provided
DATABASE_URI=mongodb://127.0.0.1/payload-notifications-dev
# PayloadCMS Secret
PAYLOAD_SECRET=your-super-secret-jwt-secret
# VAPID Keys for Web Push Notifications
# Generate with: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L
VAPID_PRIVATE_KEY=your-vapid-private-key-here
# Development Settings
NODE_ENV=development

220
dev/README.md Normal file
View File

@@ -0,0 +1,220 @@
# Payload Notifications Plugin - Development Environment
This is the development environment for testing and demonstrating the `@xtr-dev/payload-notifications` plugin.
## 🚀 Quick Start
1. **Install dependencies:**
```bash
pnpm install
```
2. **Start the development server:**
```bash
pnpm dev
```
3. **Open the demo:**
- Homepage: [http://localhost:3000](http://localhost:3000)
- Admin Panel: [http://localhost:3000/admin](http://localhost:3000/admin)
- Push Demo: [http://localhost:3000/demo](http://localhost:3000/demo)
4. **Login to admin:**
- Email: `dev@payloadcms.com`
- Password: `test`
## 🔧 Configuration
The dev environment showcases a complete implementation of the notifications plugin with:
### Collections
- **Users** - Authentication with admin/customer roles
- **Products** - Sample e-commerce products
- **Orders** - Sample orders with different statuses
- **Posts** - Blog posts for content notifications
- **Notifications** - The plugin's notifications collection
- **Push Subscriptions** - Web push subscription management
### Plugin Configuration
```typescript
notificationsPlugin({
collections: {
slug: 'notifications',
labels: { singular: 'Notification', plural: 'Notifications' }
},
relationships: [
{ name: 'order', relationTo: 'orders', label: 'Related Order' },
{ name: 'product', relationTo: 'products', label: 'Related Product', hasMany: true },
{ name: 'post', relationTo: 'posts', label: 'Related Post' }
],
access: {
read: ({ req }) => Boolean(req.user),
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => Boolean(req.user?.role === 'admin'),
},
webPush: {
enabled: true,
vapidPublicKey: process.env.VAPID_PUBLIC_KEY,
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
vapidSubject: 'mailto:test@example.com'
}
})
```
## 📱 Web Push Notifications
### Setup VAPID Keys
1. **Generate VAPID keys:**
```bash
npx web-push generate-vapid-keys
```
2. **Create a `.env` file:**
```bash
cp .env.example .env
```
3. **Add your VAPID keys to `.env`:**
```env
VAPID_PUBLIC_KEY=your-public-key
VAPID_PRIVATE_KEY=your-private-key
```
### API Endpoints
The plugin automatically creates these endpoints:
- `POST /api/push-notifications/subscribe` - Subscribe to push notifications
- `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
- `POST /api/push-notifications/test` - Send test notification (admin only)
- `POST /api/push-notifications/track` - Track notification events
### Service Worker Setup
The service worker is already configured and located at `/public/sw.js`. For new projects, generate it with:
```bash
npx @xtr-dev/payload-notifications generate-sw
```
The service worker handles:
- **Push message processing** - Receives and displays push notifications
- **Notification clicks** - Opens relevant admin panel or URLs
- **Test notifications** - Supports demo functionality
- **Analytics tracking** - Tracks notification interactions
### Testing Push Notifications
1. **Open the [demo page](http://localhost:3000/demo)**
2. **Enable notifications:**
- Click "Enable Notifications"
- Allow browser permissions when prompted
- The service worker will be registered automatically
3. **Test the system:**
- Click "Send Test Notification" to see instant notifications
- Check browser dev tools console for service worker logs
4. **Admin panel testing:**
- Go to `/admin` and create notifications
- Attach relationships to orders, products, or posts
- Real push notifications require proper VAPID keys
### Service Worker Features
- ✅ **Automatic registration** when subscribing to notifications
- ✅ **Test notification support** for immediate testing
- ✅ **Rich notification display** with actions and custom icons
- ✅ **Click handling** that opens relevant admin pages
- ✅ **Analytics tracking** for notification interactions
- ✅ **Fallback handling** for missing icons or data
## 📊 Sample Data
The development environment is automatically seeded with:
### Users
- **Admin User**: dev@payloadcms.com (password: test)
- **Customer User**: customer@example.com (password: test)
### Products
- Wireless Headphones ($299.99)
- Cotton T-Shirt ($24.99)
- JavaScript Guide ($39.99)
### Orders
- Order #ORD-001 (Shipped - Headphones + T-Shirt)
- Order #ORD-002 (Pending - JavaScript Guide)
### Notifications
- Welcome notification with blog post attachment
- Order shipped notification with order and product attachments
- Product recommendation notification (marked as read)
## 🛠️ Development
### File Structure
```
dev/
├── app/
│ ├── (app)/
│ │ ├── page.tsx # Homepage
│ │ └── demo/
│ │ └── page.tsx # Push notifications demo
│ └── (payload)/
│ ├── admin/ # Payload admin panel
│ └── api/ # API routes
├── helpers/
│ ├── credentials.ts # Default user credentials
│ └── testEmailAdapter.ts # Email testing
├── payload.config.ts # Payload configuration
├── seed.ts # Database seeding
└── .env.example # Environment variables template
```
### Environment Variables
- `DATABASE_URI` - MongoDB connection string (optional, uses in-memory DB)
- `PAYLOAD_SECRET` - JWT secret for authentication
- `VAPID_PUBLIC_KEY` - VAPID public key for web push
- `VAPID_PRIVATE_KEY` - VAPID private key for web push
- `NODE_ENV` - Environment (development/production)
### Scripts
- `pnpm dev` - Start development server
- `pnpm build` - Build the application
- `pnpm start` - Start production server
- `pnpm lint` - Run ESLint
- `pnpm test` - Run tests
## 🔍 Testing the Plugin
1. **Admin Panel Testing:**
- Create notifications with different relationship attachments
- Test read/unread functionality
- View push subscriptions
- Test user role permissions
2. **API Testing:**
- Test push notification endpoints
- Subscribe/unsubscribe from push notifications
- Send test notifications
3. **Client Integration:**
- Test the demo page functionality
- Test push notification permissions
- Test service worker integration
## 🚀 Production Deployment
1. Set up a real MongoDB database
2. Configure proper VAPID keys
3. Set up SSL certificates for push notifications
4. Configure proper environment variables
5. Deploy using your preferred platform
## 📚 Documentation
For complete documentation, see the main [README.md](../README.md) file.

493
dev/app/(app)/demo/page.tsx Normal file
View File

@@ -0,0 +1,493 @@
'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)
}
}
// Available channels (should match the configuration in payload.config.ts)
const AVAILABLE_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 },
]
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 [loading, setLoading] = useState(false)
const [selectedChannels, setSelectedChannels] = useState<string[]>(
AVAILABLE_CHANNELS.filter(channel => channel.defaultEnabled).map(channel => channel.id)
)
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)
setPushManager(manager)
setIsSupported(manager.isSupported())
setPermission(manager.getPermissionStatus())
if (manager.isSupported()) {
manager.isSubscribed().then(setIsSubscribed)
}
}, [])
const handleSubscribe = async () => {
if (!pushManager) return
setLoading(true)
try {
const subscription = await pushManager.subscribe()
// Save the subscription to Payload's database using the plugin's API endpoint
const response = await fetch('/api/push-notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription: subscription,
user: 'customer@example.com', // Associate with the demo customer user
userAgent: navigator.userAgent,
channels: selectedChannels,
}),
})
if (response.ok) {
setIsSubscribed(true)
setPermission('granted')
alert('Successfully subscribed to push notifications!\n\nSubscription saved to database.')
} else {
const error = await response.text()
throw new Error(`Failed to save subscription: ${error}`)
}
} catch (error) {
console.error('Failed to subscribe:', error)
alert('Failed to subscribe to push notifications: ' + (error as Error).message)
}
setLoading(false)
}
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')
}
} catch (error) {
console.error('Failed to unsubscribe:', error)
alert('Failed to unsubscribe from push notifications: ' + (error as Error).message)
}
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
</div>
) : (
<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>
<p style={{ marginBottom: '1rem', fontSize: '0.9rem', color: '#666' }}>
Choose which types of notifications you want to receive:
</p>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{AVAILABLE_CHANNELS.map(channel => (
<label
key={channel.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.5rem',
padding: '0.75rem',
border: '1px solid #e0e0e0',
borderRadius: '6px',
backgroundColor: selectedChannels.includes(channel.id) ? '#f0f9ff' : '#fafafa',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selectedChannels.includes(channel.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedChannels(prev => [...prev, channel.id])
} else {
setSelectedChannels(prev => prev.filter(id => id !== channel.id))
}
}}
style={{ marginTop: '0.2rem' }}
/>
<div>
<div style={{ fontWeight: '500', marginBottom: '0.25rem' }}>
{channel.name}
</div>
<div style={{ fontSize: '0.85rem', color: '#666' }}>
{channel.description}
</div>
</div>
</label>
))}
</div>
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
{!isSubscribed ? (
<button
onClick={handleSubscribe}
disabled={loading || selectedChannels.length === 0}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: selectedChannels.length === 0 ? '#ccc' : '#007FFF',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading || selectedChannels.length === 0 ? 'not-allowed' : 'pointer',
opacity: loading || selectedChannels.length === 0 ? 0.6 : 1
}}
>
{loading ? 'Subscribing...' : selectedChannels.length === 0 ? 'Select at least one channel' : 'Enable Notifications'}
</button>
) : (
<>
<button
onClick={handleUnsubscribe}
disabled={loading}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1
}}
>
{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>
</div>
)}
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>📱 Admin Panel Features</h2>
<p>The notifications plugin adds the following to your Payload admin panel:</p>
<ul>
<li><strong>Notifications Collection:</strong> Create and manage notifications with rich text content</li>
<li><strong>Push Subscriptions Collection:</strong> View and manage user push notification subscriptions (check here after subscribing!)</li>
<li><strong>📢 Channel-Based Subscriptions:</strong> Users can subscribe to specific notification channels (General, Orders, Products, Marketing)</li>
<li><strong>Read/Unread Tracking:</strong> Monitor which notifications have been read</li>
<li><strong>User Targeting:</strong> Send notifications to specific users</li>
<li><strong>🎯 Automatic Push Notifications:</strong> Push notifications are sent automatically when notifications are created!</li>
</ul>
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '2px solid #28a745', borderRadius: '8px', backgroundColor: '#f8fff8' }}>
<h2 style={{ color: '#28a745', marginBottom: '1rem' }}>🚀 Try Automatic Push Notifications</h2>
<div style={{ padding: '0.75rem', backgroundColor: '#fff3cd', borderRadius: '4px', marginBottom: '1rem', border: '1px solid #ffeaa7' }}>
<strong> Important:</strong> You must be signed in to subscribe to push notifications. The subscription associates with your user account.
</div>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 1:</strong> <a href="/admin/login" target="_blank" style={{ color: '#007FFF' }}>Sign in to the admin panel</a> first (dev@payloadcms.com / test)
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 2:</strong> Return here and subscribe to push notifications above
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 3:</strong> Go to the <a href="/admin" target="_blank" style={{ color: '#007FFF' }}>admin panel</a> and create a new notification
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 4:</strong> Set the recipient to "customer@example.com" (the test user)
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 5:</strong> Choose a notification channel (General, Orders, Products, or Marketing) - must match your subscription
</p>
<p style={{ marginBottom: '1rem' }}>
<strong>Step 6:</strong> Save the notification and watch for an automatic push notification! 🎉
</p>
<div style={{ padding: '0.75rem', backgroundColor: '#e7f3ff', borderRadius: '4px', fontSize: '0.9rem' }}>
<strong>💡 How it works:</strong> When you create a notification in the admin panel, the plugin automatically:
<ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>
<li>Extracts the title and message content</li>
<li>Finds all push subscriptions for the recipient</li>
<li>Sends push notifications to their devices</li>
<li>Handles errors gracefully without breaking the notification creation</li>
</ul>
</div>
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>🚀 API Endpoints</h2>
<p>The plugin automatically creates these API endpoints for web push:</p>
<ul>
<li><code>POST /api/push-notifications/subscribe</code> - Subscribe to push notifications</li>
<li><code>POST /api/push-notifications/unsubscribe</code> - Unsubscribe from push notifications</li>
<li><code>GET /api/push-notifications/vapid-public-key</code> - Get VAPID public key</li>
<li><code>POST /api/push-notifications/send</code> - Send notification to user</li>
<li><code>POST /api/push-notifications/test</code> - Send test notification (admin only)</li>
</ul>
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>💡 Getting Started</h2>
<ol>
<li>Generate VAPID keys: <code>npx web-push generate-vapid-keys</code></li>
<li>Add the keys to your <code>.env</code> file</li>
<li>Create a service worker at <code>/public/sw.js</code></li>
<li>Use the client-side utilities to manage subscriptions</li>
<li>Send notifications programmatically or via the admin panel</li>
</ol>
</div>
<div style={{ padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<h3>📋 Sample Data</h3>
<p>This demo includes:</p>
<ul>
<li>Sample users (admin and customer)</li>
<li>Sample notifications demonstrating channels</li>
<li>Push subscription management with channel filtering</li>
<li>Automatic push notification hooks</li>
</ul>
<p>
<strong>Login:</strong> dev@payloadcms.com / test<br/>
<strong>Admin Panel:</strong> <a href="/admin" target="_blank">/admin</a>
</p>
</div>
</div>
)
}

92
dev/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
export default function AppLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
<div style={{ minHeight: '100vh', backgroundColor: '#fafafa' }}>
<nav style={{
backgroundColor: 'white',
borderBottom: '1px solid #e5e7eb',
padding: '1rem 2rem',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.5rem' }}>🔔</span>
<span style={{ fontWeight: 'bold', fontSize: '1.1rem' }}>
Payload Notifications
</span>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<a
href="/"
style={{
color: '#007FFF',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px'
}}
>
Home
</a>
<a
href="/demo"
style={{
color: '#007FFF',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px'
}}
>
Push Demo
</a>
<a
href="/admin"
style={{
color: '#007FFF',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px',
border: '1px solid #007FFF'
}}
>
Admin Panel
</a>
</div>
</div>
</nav>
<main>
{children}
</main>
<footer style={{
marginTop: '4rem',
padding: '2rem',
textAlign: 'center',
borderTop: '1px solid #e5e7eb',
backgroundColor: 'white',
color: '#6b7280'
}}>
<p>
🔔 Payload Notifications Plugin Demo |
<a
href="https://github.com/xtr-dev/payload-notifications"
style={{ color: '#007FFF', marginLeft: '0.5rem' }}
>
Documentation
</a>
</p>
</footer>
</div>
</body>
</html>
)
}

126
dev/app/(app)/page.tsx Normal file
View File

@@ -0,0 +1,126 @@
import Link from 'next/link'
export default function HomePage() {
return (
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
<h1 style={{ fontSize: '2.5rem', marginBottom: '1rem' }}>
🔔 Payload Notifications Plugin
</h1>
<p style={{ fontSize: '1.2rem', color: '#666' }}>
A comprehensive demo of the @xtr-dev/payload-notifications plugin
</p>
</div>
<div style={{ display: 'grid', gap: '1.5rem', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
<div style={{
padding: '2rem',
border: '2px solid #007FFF',
borderRadius: '12px',
backgroundColor: '#f8f9ff'
}}>
<h2 style={{ marginBottom: '1rem', color: '#007FFF' }}>🛠 Admin Panel</h2>
<p style={{ marginBottom: '1.5rem' }}>
Access the Payload admin panel to manage notifications, users, orders, and more.
</p>
<Link
href="/admin"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#007FFF',
color: 'white',
textDecoration: 'none',
borderRadius: '6px',
fontWeight: '500'
}}
>
Open Admin Panel
</Link>
<div style={{ marginTop: '1rem', fontSize: '0.9rem', color: '#666' }}>
<strong>Login:</strong> dev@payloadcms.com / test
</div>
</div>
<div style={{
padding: '2rem',
border: '2px solid #28a745',
borderRadius: '12px',
backgroundColor: '#f8fff8'
}}>
<h2 style={{ marginBottom: '1rem', color: '#28a745' }}>📱 Push Notifications Demo</h2>
<p style={{ marginBottom: '1.5rem' }}>
Test the web push notification features and see how they work in a real application.
</p>
<Link
href="/demo"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#28a745',
color: 'white',
textDecoration: 'none',
borderRadius: '6px',
fontWeight: '500'
}}
>
View Demo
</Link>
</div>
</div>
<div style={{ marginTop: '3rem', padding: '2rem', backgroundColor: '#f8f9fa', borderRadius: '12px' }}>
<h2 style={{ marginBottom: '1.5rem' }}>🚀 What&apos;s Included</h2>
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))' }}>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}>📧 Notifications Collection</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
Rich text notifications with read/unread tracking and recipient targeting
</p>
</div>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}>🔗 Relationship Attachments</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
Link notifications to orders, products, posts, or any collection
</p>
</div>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}>🔔 Web Push Support</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
VAPID-secured push notifications for mobile and desktop browsers
</p>
</div>
<div>
<h3 style={{ color: '#007FFF', marginBottom: '0.5rem' }}> Configurable Access</h3>
<p style={{ fontSize: '0.9rem', color: '#666' }}>
Flexible access control with role-based permissions
</p>
</div>
</div>
</div>
<div style={{ marginTop: '2rem', padding: '1.5rem', border: '1px solid #dee2e6', borderRadius: '8px' }}>
<h3 style={{ marginBottom: '1rem' }}>📋 Sample Data</h3>
<p style={{ marginBottom: '1rem' }}>This demo environment includes:</p>
<ul style={{ marginLeft: '1.5rem', color: '#666' }}>
<li>Admin user (dev@payloadcms.com) and customer user</li>
<li>Sample products (headphones, t-shirt, JavaScript guide)</li>
<li>Sample orders with different statuses</li>
<li>Sample notifications with relationship attachments</li>
<li>Push subscription management</li>
</ul>
</div>
<div style={{ marginTop: '2rem', textAlign: 'center', color: '#666' }}>
<p>
📖 <a href="https://github.com/xtr-dev/payload-notifications" style={{ color: '#007FFF' }}>
View Documentation
</a> |
🐙 <a href="https://github.com/xtr-dev/payload-notifications" style={{ color: '#007FFF' }}>
GitHub Repository
</a>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })
export default NotFound

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })
export default Page

View File

@@ -0,0 +1,49 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
}

View File

@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

View File

@@ -0,0 +1,32 @@
import type { ServerFunctionClient } from 'payload'
import '@payloadcms/next/css'
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

14
dev/app/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Payload Notifications Plugin Demo',
description: 'Demo environment for the @xtr-dev/payload-notifications plugin',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

12
dev/app/my-route/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export const GET = async (request: Request) => {
const payload = await getPayload({
config: configPromise,
})
return Response.json({
message: 'This is an example of a custom route.',
})
}

15
dev/e2e.spec.ts Normal file
View File

@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'
// this is an example Playwright e2e test
test('should render admin panel logo', async ({ page }) => {
await page.goto('/admin')
// login
await page.fill('#field-email', 'dev@payloadcms.com')
await page.fill('#field-password', 'test')
await page.click('.form-submit button')
// should show dashboard
await expect(page).toHaveTitle(/Dashboard/)
await expect(page.locator('.graphic-icon')).toBeVisible()
})

View File

@@ -0,0 +1,4 @@
export const devUser = {
email: 'dev@payloadcms.com',
password: 'test',
}

View File

@@ -0,0 +1,38 @@
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
}

52
dev/int.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Payload } from 'payload'
import config from '@payload-config'
import { createPayloadRequest, getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
let payload: Payload
afterAll(async () => {
await payload.destroy()
})
beforeAll(async () => {
payload = await getPayload({ config })
})
describe('Plugin integration tests', () => {
test('should query custom endpoint added by plugin', async () => {
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
method: 'GET',
})
const payloadRequest = await createPayloadRequest({ config, request })
const response = await customEndpointHandler(payloadRequest)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toMatchObject({
message: 'Hello from custom endpoint',
})
})
test('can create post with custom text field added by plugin', async () => {
const post = await payload.create({
collection: 'posts',
data: {
addedByPlugin: 'added by plugin',
},
})
expect(post.addedByPlugin).toBe('added by plugin')
})
test('plugin creates and seeds plugin-collection', async () => {
expect(payload.collections['plugin-collection']).toBeDefined()
const { docs } = await payload.find({ collection: 'plugin-collection' })
expect(docs).toHaveLength(1)
})
})

5
dev/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

21
dev/next.config.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { withPayload } from '@payloadcms/next/withPayload'
import { fileURLToPath } from 'url'
import path from 'path'
const dirname = path.dirname(fileURLToPath(import.meta.url))
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
serverExternalPackages: ['mongodb-memory-server'],
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

531
dev/payload-types.ts Normal file
View File

@@ -0,0 +1,531 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
orders: Order;
products: Product;
posts: Post;
media: Media;
notifications: Notification;
'push-subscriptions': PushSubscription;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
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>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
role: 'admin' | 'customer';
firstName?: string | null;
lastName?: string | null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
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
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "notifications".
*/
export interface Notification {
id: string;
/**
* The notification title that will be displayed to users
*/
title: string;
/**
* The notification message content
*/
message: {
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;
};
/**
* The user who should receive this notification
*/
recipient: string | User;
/**
* Whether this notification has been read by the recipient
*/
isRead?: boolean | null;
/**
* 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;
}
/**
* Web push notification subscriptions for users
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "push-subscriptions".
*/
export interface PushSubscription {
id: string;
/**
* The user this push subscription belongs to
*/
user: string | User;
/**
* Push service endpoint URL
*/
endpoint: string;
/**
* User agent public key for encryption
*/
p256dh: string;
/**
* User agent authentication secret
*/
auth: string;
/**
* Browser/device information
*/
userAgent?: string | null;
/**
* Whether this subscription is still active
*/
isActive?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
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;
} | null)
| ({
relationTo: 'push-subscriptions';
value: string | PushSubscription;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
role?: T;
firstName?: T;
lastName?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
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".
*/
export interface NotificationsSelect<T extends boolean = true> {
title?: T;
message?: T;
recipient?: T;
isRead?: T;
readAt?: T;
attachments?:
| T
| {
order?: T;
product?: T;
post?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "push-subscriptions_select".
*/
export interface PushSubscriptionsSelect<T extends boolean = true> {
user?: T;
endpoint?: T;
p256dh?: T;
auth?: T;
userAgent?: T;
isActive?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

176
dev/payload.config.ts Normal file
View File

@@ -0,0 +1,176 @@
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 { notificationsPlugin } from '@xtr-dev/payload-notifications'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
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),
},
},
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',
},
],
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
plugins: [
// Demo of the notifications plugin with relationships and channels
notificationsPlugin({
collections: {
slug: 'notifications',
labels: {
singular: 'Notification',
plural: 'Notifications'
}
},
channels: [
{
id: 'general',
name: 'General Notifications',
description: 'General updates and announcements',
defaultEnabled: true
},
{
id: 'orders',
name: 'Order Updates',
description: 'Order status changes and shipping notifications',
defaultEnabled: true
},
{
id: 'products',
name: 'Product Updates',
description: 'New products, restocks, and price changes',
defaultEnabled: false
},
{
id: 'marketing',
name: 'Marketing & Promotions',
description: 'Special offers, sales, and promotional content',
defaultEnabled: false
}
],
access: {
read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'),
},
webPush: {
enabled: true,
autoPush: true, // Enable automatic push notifications
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 ? '...' : '')
}
}
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()

View File

@@ -0,0 +1,27 @@
# Notification Icons
This directory contains icons for web push notifications.
## Required Icons:
- `notification-icon.png` - Main notification icon (recommended: 192x192px)
- `notification-badge.png` - Small badge icon (recommended: 72x72px)
- `view.png` - View action icon (recommended: 32x32px)
- `dismiss.png` - Dismiss action icon (recommended: 32x32px)
## Icon Requirements:
1. **Format**: PNG with transparency support
2. **Size**: Multiple sizes recommended (72x72, 96x96, 128x128, 192x192, 256x256, 512x512)
3. **Design**: Simple, clear, recognizable at small sizes
4. **Background**: Transparent or solid color that works on any background
## Fallback:
If custom icons are not provided, the service worker will use these default paths:
- `/icons/notification-icon.png`
- `/icons/notification-badge.png`
- `/icons/view.png`
- `/icons/dismiss.png`
You can create simple colored PNG files or use emoji-based icons for testing.

197
dev/public/sw.js Normal file
View File

@@ -0,0 +1,197 @@
/**
* Service Worker for Web Push Notifications
* Payload Notifications Plugin Demo
*/
console.log('[SW] Service worker loaded')
// Service worker lifecycle events
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker')
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker')
event.waitUntil(self.clients.claim())
})
// Handle push events
self.addEventListener('push', (event) => {
console.log('[SW] Push event received')
if (!event.data) {
console.log('[SW] Push event has no data')
return
}
try {
const payload = event.data.json()
console.log('[SW] Push payload:', payload)
const { title, body, icon, badge, image, data, actions, tag, requireInteraction } = payload
const notificationOptions = {
body,
icon: icon || '/icons/notification-icon.png',
badge: badge || '/icons/notification-badge.png',
image,
data,
actions: actions || [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
],
tag: tag || 'notification',
requireInteraction: requireInteraction || false,
timestamp: Date.now(),
vibrate: [200, 100, 200],
renotify: true,
}
event.waitUntil(
self.registration.showNotification(title || 'New Notification', notificationOptions)
)
} catch (error) {
console.error('[SW] Error processing push notification:', error)
// Fallback notification
event.waitUntil(
self.registration.showNotification('New Notification', {
body: 'You have a new notification',
icon: '/icons/notification-icon.png',
badge: '/icons/notification-badge.png',
tag: 'fallback',
})
)
}
})
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification click received')
console.log('[SW] Action:', event.action)
console.log('[SW] Notification data:', event.notification.data)
event.notification.close()
const data = event.notification.data || {}
// Handle action button clicks
if (event.action) {
switch (event.action) {
case 'view':
if (data.url) {
event.waitUntil(
clients.openWindow(data.url)
)
} else {
event.waitUntil(
clients.openWindow('/admin/collections/notifications')
)
}
break
case 'dismiss':
// Just close the notification (already done above)
break
default:
console.log('[SW] Unknown action:', event.action)
}
} else {
// Default click behavior - open the admin panel or specific URL
const urlToOpen = data.url || '/admin/collections/notifications'
event.waitUntil(
clients.matchAll({ type: 'window' }).then((windowClients) => {
// Check if there is already an open window
for (const client of windowClients) {
if (client.url.includes('/admin') && 'focus' in client) {
return client.focus()
}
}
// If no admin window is open, open a new one
return clients.openWindow(urlToOpen)
})
)
}
// Track notification click
if (data.notificationId) {
fetch('/api/push-notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'click',
notificationId: data.notificationId,
timestamp: Date.now(),
}),
}).catch((error) => {
console.error('[SW] Failed to track notification click:', error)
})
}
})
// Handle notification close events
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed:', event.notification.tag)
const data = event.notification.data || {}
// Track notification close
if (data.notificationId) {
fetch('/api/push-notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'close',
notificationId: data.notificationId,
timestamp: Date.now(),
}),
}).catch((error) => {
console.error('[SW] Failed to track notification close:', error)
})
}
})
// Handle background sync (optional)
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag)
if (event.tag === 'push-notification-sync') {
event.waitUntil(
// Handle offline notification sync
Promise.resolve()
)
}
})
// Handle message events from the main thread
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data)
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
// Handle test notifications sent from the demo page
if (event.data && event.data.type === 'TEST_NOTIFICATION') {
const payload = event.data.payload
self.registration.showNotification(payload.title, {
body: payload.body,
icon: payload.icon,
badge: payload.badge,
data: payload.data,
actions: [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
],
tag: 'test-notification',
requireInteraction: false,
timestamp: Date.now(),
vibrate: [200, 100, 200],
})
}
})
console.log('[SW] Service worker setup complete')

183
dev/seed.ts Normal file
View File

@@ -0,0 +1,183 @@
import type { Payload } from 'payload'
import { devUser } from './helpers/credentials.ts'
export const seed = async (payload: Payload) => {
console.log('Seeding database...')
// Check if admin user exists
const { totalDocs } = await payload.count({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
let adminUser: any
let customerUser: any
if (!totalDocs) {
// Create admin user
adminUser = await payload.create({
collection: 'users',
data: {
...devUser,
role: 'admin',
firstName: 'Admin',
lastName: 'User',
},
})
console.log('✅ Created admin user:', devUser.email)
// Create sample customer user
customerUser = await payload.create({
collection: 'users',
data: {
email: 'customer@example.com',
password: 'test',
role: 'customer',
firstName: 'John',
lastName: 'Customer',
},
})
console.log('✅ Created customer user')
} else {
// Get existing users
const existingAdmin = await payload.find({
collection: 'users',
where: { email: { equals: devUser.email } },
limit: 1,
})
adminUser = existingAdmin.docs[0]
const existingCustomer = await payload.find({
collection: 'users',
where: { email: { equals: 'customer@example.com' } },
limit: 1,
})
customerUser = existingCustomer.docs[0]
}
// Check if sample notifications already exist
const existingNotifications = await payload.count({ collection: 'notifications' })
if (existingNotifications.totalDocs === 0) {
// Create sample notifications
await payload.create({
collection: 'notifications',
data: {
title: 'Welcome to the Demo!',
message: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Welcome to the notifications plugin demo! This notification was created during the seeding process. Try creating your own notifications and watch the automatic push notifications work.',
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
},
recipient: customerUser.id,
channel: 'general',
isRead: false,
},
})
await payload.create({
collection: 'notifications',
data: {
title: 'Orders Channel Demo',
message: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'This is a sample notification for the Orders channel. Users subscribed to the Orders channel will receive notifications like this one.',
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
},
recipient: customerUser.id,
channel: 'orders',
isRead: false,
},
})
await payload.create({
collection: 'notifications',
data: {
title: 'New Product Recommendation',
message: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'This is a sample notification for the Products channel. This notification has been marked as read to demonstrate the read/unread functionality.',
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
},
recipient: customerUser.id,
channel: 'products',
isRead: true,
readAt: new Date().toISOString(),
},
})
console.log('✅ Created sample notifications')
console.log('\n🎉 Database seeded successfully!')
console.log('\n📝 You can now:')
console.log(' • Login as admin: dev@payloadcms.com / test')
console.log(' • View notifications in the admin panel')
console.log(' • Create new notifications and watch automatic push notifications!')
console.log(' • Test the channel-based subscription system')
console.log(' • Try the demo at /demo to subscribe to push notifications')
} else {
console.log('✅ Sample data already exists, skipping seed')
}
}

43
dev/tsconfig.json Normal file
View File

@@ -0,0 +1,43 @@
{
"extends": "../tsconfig.json",
"exclude": [],
"include": [
"**/*.js",
"**/*.jsx",
"**/*.mjs",
"**/*.cjs",
"**/*.ts",
"**/*.tsx",
"next.config.mjs",
".next/types/**/*.ts"
],
"compilerOptions": {
"baseUrl": "./",
"rootDir": "./",
"paths": {
"@payload-config": [
"./payload.config.ts"
],
"@xtr-dev/payload-notifications": [
"../src/index.ts"
],
"@xtr-dev/payload-notifications/client": [
"../src/exports/client.ts"
],
"@xtr-dev/payload-notifications/rsc": [
"../src/exports/rsc.ts"
]
},
"noEmit": true,
"emitDeclarationOnly": false,
"allowJs": true,
"incremental": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
}
}

46
eslint.config.js Normal file
View File

@@ -0,0 +1,46 @@
// @ts-check
import payloadEsLintConfig from '@payloadcms/eslint-config'
export const defaultESLintIgnores = [
'**/.temp',
'**/.*', // ignore all dotfiles
'**/.git',
'**/.hg',
'**/.pnp.*',
'**/.svn',
'**/playwright.config.ts',
'**/vitest.config.js',
'**/tsconfig.tsbuildinfo',
'**/README.md',
'**/eslint.config.js',
'**/payload-types.ts',
'**/dist/',
'**/.yarn/',
'**/build/',
'**/node_modules/',
'**/temp/',
]
export default [
...payloadEsLintConfig,
{
rules: {
'no-restricted-exports': 'off',
},
},
{
languageOptions: {
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
projectService: {
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'],
},
// projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
]

120
package.json Normal file
View File

@@ -0,0 +1,120 @@
{
"name": "@xtr-dev/payload-notifications",
"version": "1.0.0",
"description": "A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --outDir dist --rootDir ./src",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"dev": "next dev dev --turbo",
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
"dev:generate-types": "pnpm dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"generate:importmap": "pnpm dev:generate-importmap",
"generate:types": "pnpm dev:generate-types",
"lint": "eslint",
"lint:fix": "eslint ./src --fix",
"prepublishOnly": "pnpm clean && pnpm build",
"test": "pnpm test:int && pnpm test:e2e",
"test:e2e": "playwright test",
"test:int": "vitest"
},
"keywords": [
"payloadcms",
"plugin",
"notifications",
"cms",
"relationships"
],
"author": "XTR Dev",
"repository": {
"type": "git",
"url": "git+https://github.com/xtr-dev/payload-notifications.git"
},
"bugs": {
"url": "https://github.com/xtr-dev/payload-notifications/issues"
},
"homepage": "https://github.com/xtr-dev/payload-notifications#readme",
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "3.37.0",
"@payloadcms/db-postgres": "3.37.0",
"@payloadcms/db-sqlite": "3.37.0",
"@payloadcms/eslint-config": "3.9.0",
"@payloadcms/next": "3.37.0",
"@payloadcms/richtext-lexical": "3.37.0",
"@payloadcms/ui": "3.37.0",
"@playwright/test": "^1.52.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
"@types/node": "^22.5.4",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/web-push": "^3.6.3",
"copyfiles": "2.4.1",
"cross-env": "^7.0.3",
"eslint": "^9.23.0",
"eslint-config-next": "15.4.4",
"graphql": "^16.8.1",
"mongodb-memory-server": "10.1.4",
"next": "15.4.4",
"open": "^10.1.0",
"payload": "3.37.0",
"prettier": "^3.4.2",
"qs-esm": "7.0.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"rimraf": "3.0.2",
"sharp": "0.34.2",
"sort-package-json": "^2.10.0",
"typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2"
},
"peerDependencies": {
"payload": "^3.37.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"esbuild",
"unrs-resolver"
]
},
"registry": "https://registry.npmjs.org/",
"dependencies": {
"web-push": "^3.6.7"
},
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
}

46
playwright.config.js Normal file
View File

@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './dev',
testMatch: '**/e2e.spec.{ts,js}',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
reuseExistingServer: true,
url: 'http://localhost:3000/admin',
},
})

11416
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

254
src/client/push-manager.ts Normal file
View File

@@ -0,0 +1,254 @@
/**
* Client-side Push Notification Manager
* Handles subscription, permission requests, and communication with the server
*
* @description This module is designed to run in browser environments only
*/
export interface PushSubscriptionData {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
// Check if we're in a browser environment
const isBrowser = typeof window !== 'undefined'
export class ClientPushManager {
private vapidPublicKey: string
private serviceWorkerPath: string
private apiEndpoint: string
constructor(
vapidPublicKey: string,
options: {
serviceWorkerPath?: string
apiEndpoint?: string
} = {}
) {
this.vapidPublicKey = vapidPublicKey
this.serviceWorkerPath = options.serviceWorkerPath || '/sw.js'
this.apiEndpoint = options.apiEndpoint || '/api/push-notifications'
}
/**
* Check if push notifications are supported
*/
public isSupported(): boolean {
if (!isBrowser) return false
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
)
}
/**
* Check current notification permission status
*/
public getPermissionStatus(): NotificationPermission {
if (!isBrowser || typeof Notification === 'undefined') return 'default'
return Notification.permission
}
/**
* Request notification permission from user
*/
public async requestPermission(): Promise<NotificationPermission> {
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
const permission = await Notification.requestPermission()
return permission
}
/**
* Register service worker
*/
public async registerServiceWorker(): Promise<ServiceWorkerRegistration> {
if (!isBrowser || !('serviceWorker' in navigator)) {
throw new Error('Service workers are not supported')
}
try {
const registration = await navigator.serviceWorker.register(this.serviceWorkerPath)
console.log('Service worker registered:', registration)
return registration
} catch (error) {
console.error('Service worker registration failed:', error)
throw error
}
}
/**
* Subscribe to push notifications
*/
public async subscribe(): Promise<PushSubscriptionData> {
// Check support
if (!this.isSupported()) {
throw new Error('Push notifications are not supported')
}
// Request permission
const permission = await this.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission not granted')
}
// Register service worker
const registration = await this.registerServiceWorker()
// Wait for service worker to be ready
await navigator.serviceWorker.ready
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
})
const subscriptionData: PushSubscriptionData = {
endpoint: subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
},
}
// Send subscription to server
await this.sendSubscriptionToServer(subscriptionData)
return subscriptionData
}
/**
* Unsubscribe from push notifications
*/
public async unsubscribe(): Promise<void> {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
return
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
return
}
// Unsubscribe from push service
await subscription.unsubscribe()
// Notify server
await this.sendUnsubscribeToServer(subscription.endpoint)
}
/**
* Get current push subscription
*/
public async getSubscription(): Promise<PushSubscriptionData | null> {
if (!isBrowser || !('serviceWorker' in navigator)) return null
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
return null
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
return null
}
return {
endpoint: subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
},
}
}
/**
* Check if user is currently subscribed
*/
public async isSubscribed(): Promise<boolean> {
if (!isBrowser) return false
const subscription = await this.getSubscription()
return subscription !== null
}
/**
* Send subscription data to server
*/
private async sendSubscriptionToServer(subscription: PushSubscriptionData): Promise<void> {
try {
const response = await fetch(`${this.apiEndpoint}/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription,
userAgent: navigator.userAgent,
}),
})
if (!response.ok) {
throw new Error(`Failed to subscribe: ${response.statusText}`)
}
} catch (error) {
console.error('Failed to send subscription to server:', error)
throw error
}
}
/**
* Send unsubscribe request to server
*/
private async sendUnsubscribeToServer(endpoint: string): Promise<void> {
try {
const response = await fetch(`${this.apiEndpoint}/unsubscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint }),
})
if (!response.ok) {
throw new Error(`Failed to unsubscribe: ${response.statusText}`)
}
} catch (error) {
console.error('Failed to send unsubscribe to server:', error)
throw error
}
}
/**
* Convert VAPID public key to Uint8Array
*/
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
/**
* Convert ArrayBuffer to base64 string
*/
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
return window.btoa(binary)
}
}

View File

@@ -0,0 +1,132 @@
/**
* Service Worker for Web Push Notifications
* This file should be served as a static file (e.g., /sw.js)
*/
declare const self: ServiceWorkerGlobalScope
interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
timestamp: number
}
self.addEventListener('install', (event: ExtendableEvent) => {
console.log('[SW] Installing service worker')
self.skipWaiting()
})
self.addEventListener('activate', (event: ExtendableEvent) => {
console.log('[SW] Activating service worker')
event.waitUntil(self.clients.claim())
})
// Handle push events
self.addEventListener('push', (event: PushEvent) => {
console.log('[SW] Push event received')
if (!event.data) {
console.log('[SW] Push event has no data')
return
}
try {
const payload: NotificationPayload = event.data.json()
const { title, body, ...options } = payload
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: options.icon || '/icon-192x192.png',
badge: options.badge || '/badge-72x72.png',
data: options.data,
actions: options.actions,
tag: options.tag,
requireInteraction: options.requireInteraction || false,
timestamp: options.timestamp || Date.now(),
vibrate: [200, 100, 200],
renotify: true,
} as NotificationOptions)
)
} catch (error) {
console.error('[SW] Error processing push notification:', error)
}
})
// Handle notification clicks
self.addEventListener('notificationclick', (event: NotificationEvent) => {
console.log('[SW] Notification click received')
event.notification.close()
const data = event.notification.data || {}
// Handle action button clicks
if (event.action) {
console.log('[SW] Action clicked:', event.action)
// Custom action handling based on action type
switch (event.action) {
case 'view':
if (data.url) {
event.waitUntil(
self.clients.openWindow(data.url)
)
}
break
case 'dismiss':
// Just close the notification
break
default:
console.log('[SW] Unknown action:', event.action)
}
} else {
// Default click behavior - open the app
const urlToOpen = data.url || '/'
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((windowClients: readonly WindowClient[]) => {
// Check if there is already an open window
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus()
}
}
// If no window is open, open a new one
if (self.clients.openWindow) {
return self.clients.openWindow(urlToOpen)
}
})
)
}
})
// Handle notification close events
self.addEventListener('notificationclose', (event: NotificationEvent) => {
console.log('[SW] Notification closed:', event.notification.tag)
// Optional: Send analytics or tracking data
const data = event.notification.data || {}
if (data.trackClose) {
// Send tracking data to your analytics service
fetch('/api/notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'close',
notificationId: data.id,
timestamp: Date.now(),
}),
}).catch(console.error)
}
})
export {}

View File

@@ -0,0 +1,269 @@
import type { CollectionConfig, Field } from 'payload'
import type { NotificationsPluginOptions, NotificationAccess } from '../types'
import { buildRelationshipFields } from '../utils/buildFields'
import { WebPushManager } from '../utils/webPush'
import { defaultNotificationTransformer } from '../utils/richTextExtractor'
/**
* Creates the notifications collection configuration
* Includes core fields plus dynamically generated relationship fields
*/
export function createNotificationsCollection(options: NotificationsPluginOptions = {}): CollectionConfig {
const {
collections = {},
relationships = [],
access = {},
fields: customFields = [],
} = options
const slug = collections.slug || 'notifications'
const labels = {
singular: collections.labels?.singular || 'Notification',
plural: collections.labels?.plural || 'Notifications',
}
// Default access control - authenticated users can read, admins can manage
const defaultAccess: NotificationAccess = {
read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'),
}
// Build channel field if channels are configured
const channelField: Field[] = options.channels && options.channels.length > 0 ? [
{
name: 'channel',
type: 'select',
label: 'Channel',
options: options.channels.map(channel => ({
label: channel.name,
value: channel.id,
})),
required: false,
admin: {
description: 'The notification channel - only subscribers to this channel will receive the notification',
position: 'sidebar',
},
},
] : []
// Default recipient field (relationship to users)
// Users can add custom recipient fields via the fields option and use findSubscriptions hook
const recipientField: Field = {
name: 'recipient',
type: 'relationship',
relationTo: 'users',
label: 'Recipient',
required: false,
admin: {
description: 'The user who should receive this notification (optional if using custom recipient fields)',
},
}
// Build core fields
const coreFields: Field[] = [
{
name: 'title',
type: 'text',
label: 'Title',
required: true,
admin: {
description: 'The notification title that will be displayed to users',
},
},
{
name: 'message',
type: 'richText',
label: 'Message',
required: true,
admin: {
description: 'The notification message content',
},
},
recipientField,
...channelField,
{
name: 'isRead',
type: 'checkbox',
label: 'Read',
defaultValue: false,
admin: {
description: 'Whether this notification has been read by the recipient',
position: 'sidebar',
},
},
{
name: 'readAt',
type: 'date',
label: 'Read At',
admin: {
description: 'When this notification was marked as read',
position: 'sidebar',
condition: (_: any, siblingData: any) => siblingData?.isRead,
},
},
]
// Build relationship fields
const relationshipFields = buildRelationshipFields(relationships)
// Combine all fields
const allFields = [...coreFields, ...relationshipFields, ...customFields]
const config: CollectionConfig = {
slug,
labels,
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'recipient', 'isRead', 'createdAt'],
description: 'Manage user notifications and messaging',
},
fields: allFields,
access: {
read: access.read || defaultAccess.read!,
create: access.create || defaultAccess.create!,
update: access.update || defaultAccess.update!,
delete: access.delete || defaultAccess.delete!,
},
timestamps: true,
}
// Add hooks for automatic push notifications if web push is enabled
if (options.webPush?.enabled && options.webPush.autoPush) {
config.hooks = {
afterChange: [
async ({ doc, operation, req }) => {
// Only send push notifications for new notifications
if (operation !== 'create') return
try {
const webPushConfig = options.webPush!
const pushManager = new WebPushManager(webPushConfig, req.payload)
// Transform notification content using custom transformer or default
const transformer = webPushConfig.transformNotification || defaultNotificationTransformer
const pushContent = transformer(doc)
console.log('[Notifications Plugin] Sending push notification for notification:', doc.id)
console.log('[Notifications Plugin] Push content:', pushContent)
let results: Array<{ success: boolean; error?: any }> = []
// Check if custom findSubscriptions hook is provided
if (webPushConfig.findSubscriptions) {
// Use custom hook to find subscriptions
console.log('[Notifications Plugin] Using custom findSubscriptions hook')
const subscriptions = await webPushConfig.findSubscriptions(doc, req.payload)
if (!subscriptions || subscriptions.length === 0) {
console.log('[Notifications Plugin] No subscriptions found via custom hook')
return
}
// Send notifications directly to the found subscriptions
const notificationPayload = JSON.stringify({
title: pushContent.title,
body: pushContent.body,
icon: pushContent.icon || '/icon-192x192.png',
badge: pushContent.badge || '/badge-72x72.png',
image: pushContent.image,
data: pushContent.data,
actions: pushContent.actions,
tag: pushContent.tag,
requireInteraction: pushContent.requireInteraction || false,
timestamp: Date.now(),
})
results = await Promise.allSettled(
subscriptions.map(async (sub: any) => {
try {
const pushSub = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
}
await pushManager.sendNotification(pushSub, notificationPayload)
return { success: true }
} catch (error: any) {
// Handle expired/invalid subscriptions
if (error.statusCode === 410 || error.statusCode === 404) {
await req.payload.update({
collection: 'push-subscriptions',
id: sub.id,
data: { isActive: false },
})
}
return { success: false, error }
}
})
).then(results =>
results.map((result) =>
result.status === 'fulfilled'
? result.value
: { success: false, error: result.reason }
)
)
} else {
// Use default behavior - send to recipient user (if recipient is provided)
if (!doc.recipient) {
console.warn('[Notifications Plugin] No recipient found and no findSubscriptions hook provided - skipping push notification')
return
}
let recipientId: string
if (typeof doc.recipient === 'string') {
recipientId = doc.recipient
} else if (doc.recipient?.id) {
recipientId = doc.recipient.id
} else {
console.warn('[Notifications Plugin] No valid recipient found for push notification')
return
}
console.log('[Notifications Plugin] Using default user-based recipient logic for:', recipientId)
// Send push notification to the recipient user
results = await pushManager.sendToRecipient(
recipientId,
pushContent.title,
pushContent.body,
{
icon: pushContent.icon,
badge: pushContent.badge,
image: pushContent.image,
data: pushContent.data,
actions: pushContent.actions,
tag: pushContent.tag,
requireInteraction: pushContent.requireInteraction,
channel: doc.channel,
recipientType: 'user',
}
)
}
const successful = results.filter(r => r.success).length
const failed = results.filter(r => !r.success).length
console.log(`[Notifications Plugin] Push notification results: ${successful} sent, ${failed} failed`)
if (failed > 0) {
console.warn('[Notifications Plugin] Some push notifications failed:',
results.filter(r => !r.success).map(r => r.error)
)
}
} catch (error) {
console.error('[Notifications Plugin] Error sending push notification:', error)
// Don't throw error - we don't want to prevent notification creation
}
},
],
}
}
return config
}

View File

@@ -0,0 +1,123 @@
import type { CollectionConfig } from 'payload'
import type { NotificationAccess, NotificationsPluginOptions } from '../types'
/**
* Creates a collection to store web push subscriptions
* Each user can have multiple subscriptions (different devices/browsers)
*/
export function createPushSubscriptionsCollection(access: NotificationAccess = {}, options: NotificationsPluginOptions = {}): CollectionConfig {
const defaultAccess: NotificationAccess = {
read: ({ req }: { req: any }) => Boolean(req.user),
create: ({ req }: { req: any }) => Boolean(req.user),
update: ({ req }: { req: any }) => Boolean(req.user),
delete: ({ req }: { req: any }) => Boolean(req.user),
}
return {
slug: 'push-subscriptions',
labels: {
singular: 'Push Subscription',
plural: 'Push Subscriptions',
},
admin: {
useAsTitle: 'endpoint',
defaultColumns: ['user', 'endpoint', 'createdAt'],
description: 'Web push notification subscriptions for users',
// hidden: true, // Hide from main navigation
},
fields: [
{
name: 'user',
type: 'relationship',
relationTo: 'users',
label: 'User',
required: true,
admin: {
description: 'The user this push subscription belongs to',
},
},
{
name: 'endpoint',
type: 'text',
label: 'Endpoint',
required: true,
unique: true,
admin: {
description: 'Push service endpoint URL',
},
},
{
name: 'p256dh',
type: 'text',
label: 'P256DH Key',
required: true,
admin: {
description: 'User agent public key for encryption',
},
},
{
name: 'auth',
type: 'text',
label: 'Auth Secret',
required: true,
admin: {
description: 'User agent authentication secret',
},
},
{
name: 'userAgent',
type: 'text',
label: 'User Agent',
admin: {
description: 'Browser/device information',
},
},
{
name: 'channels',
type: 'select',
label: 'Subscribed Channels',
options: options.channels && options.channels.length > 0
? options.channels.map(channel => ({
label: channel.name,
value: channel.id,
}))
: [{ label: 'All Notifications', value: 'all' }],
hasMany: true,
defaultValue: options.channels && options.channels.length > 0
? options.channels.filter(channel => channel.defaultEnabled !== false).map(channel => channel.id)
: ['all'],
admin: {
description: 'Channels this subscription is subscribed to - leave empty for all notifications',
},
},
{
name: 'isActive',
type: 'checkbox',
label: 'Active',
defaultValue: true,
admin: {
description: 'Whether this subscription is still active',
position: 'sidebar',
},
},
],
access: {
read: access.read || defaultAccess.read!,
create: access.create || defaultAccess.create!,
update: access.update || defaultAccess.update!,
delete: access.delete || defaultAccess.delete!,
},
timestamps: true,
hooks: {
beforeChange: [
({ req, data }: { req: any; data: any }) => {
// For user-based subscriptions, default to current user
if (req.user && !data.user) {
data.user = req.user.id
}
return data
},
],
},
}
}

View File

@@ -0,0 +1,250 @@
import type { Endpoint, PayloadRequest } from 'payload'
import { WebPushManager } from '../utils/webPush'
import type { NotificationsPluginOptions } from '../types'
/**
* Create push notification API endpoints
*/
export function createPushNotificationEndpoints(options: NotificationsPluginOptions): Endpoint[] {
if (!options.webPush?.enabled) {
return []
}
const webPushConfig = options.webPush
return [
// Subscribe endpoint
{
path: '/push-notifications/subscribe',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
if (!req.user) {
return Response.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { subscription, userAgent, channels } = body
if (!subscription || !subscription.endpoint) {
return Response.json({ error: 'Invalid subscription data' }, { status: 400 })
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
await pushManager.subscribe(
String(req.user.id),
subscription,
userAgent,
channels
)
return Response.json({ success: true })
} catch (error: any) {
console.error('Push subscription error:', error)
return Response.json(
{ error: 'Failed to subscribe to push notifications' },
{ status: 500 }
)
}
},
},
// Unsubscribe endpoint
{
path: '/push-notifications/unsubscribe',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { endpoint } = body
if (!endpoint) {
return Response.json({ error: 'Endpoint is required' }, { status: 400 })
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
await pushManager.unsubscribe(endpoint)
return Response.json({ success: true })
} catch (error: any) {
console.error('Push unsubscribe error:', error)
return Response.json(
{ error: 'Failed to unsubscribe from push notifications' },
{ status: 500 }
)
}
},
},
// Get VAPID public key
{
path: '/push-notifications/vapid-public-key',
method: 'get',
handler: async (req: PayloadRequest) => {
try {
return Response.json({
publicKey: webPushConfig.vapidPublicKey,
})
} catch (error: any) {
console.error('VAPID key error:', error)
return Response.json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
)
}
},
},
// Send test notification (admin only)
{
path: '/push-notifications/test',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
if (!req.user || req.user.role !== 'admin') {
return Response.json({ error: 'Admin access required' }, { status: 403 })
}
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, title, body: messageBody, options: notificationOptions } = body
if (!userId || !title || !messageBody) {
return Response.json(
{ error: 'userId, title, and body are required' },
{ status: 400 }
)
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
const results = await pushManager.sendToUser(
userId,
title,
messageBody,
notificationOptions
)
return Response.json({
success: true,
results,
sent: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
})
} catch (error: any) {
console.error('Test notification error:', error)
return Response.json(
{ error: 'Failed to send test notification' },
{ status: 500 }
)
}
},
},
// Send notification to user (authenticated users can send to themselves, admins to anyone)
{
path: '/push-notifications/send',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
if (!req.user) {
return Response.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, title, body: messageBody, options: notificationOptions } = body
if (!userId || !title || !messageBody) {
return Response.json(
{ error: 'userId, title, and body are required' },
{ status: 400 }
)
}
// Users can only send notifications to themselves, admins can send to anyone
if (userId !== req.user.id && req.user.role !== 'admin') {
return Response.json(
{ error: 'You can only send notifications to yourself' },
{ status: 403 }
)
}
const pushManager = new WebPushManager(webPushConfig, req.payload)
const results = await pushManager.sendToUser(
userId,
title,
messageBody,
notificationOptions
)
return Response.json({
success: true,
results,
sent: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
})
} catch (error: any) {
console.error('Send notification error:', error)
return Response.json(
{ error: 'Failed to send notification' },
{ status: 500 }
)
}
},
},
// Tracking endpoint for analytics
{
path: '/push-notifications/track',
method: 'post',
handler: async (req: PayloadRequest) => {
try {
const body = await req.json?.()
if (!body) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
const { action, notificationId, timestamp } = body
// Log the tracking event (you can extend this to save to database)
console.log('Push notification tracking:', {
action,
notificationId,
timestamp,
userAgent: req.headers.get('user-agent'),
// Note: req.ip may not be available in all environments
})
// You could save tracking data to a collection here
// await req.payload.create({
// collection: 'notification-analytics',
// data: { action, notificationId, timestamp, ... }
// })
return Response.json({ success: true })
} catch (error: any) {
console.error('Tracking error:', error)
return Response.json(
{ error: 'Failed to track notification event' },
{ status: 500 }
)
}
},
},
]
}

190
src/exports/client.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* Client-side exports for the notifications plugin
* Import from '@xtr-dev/payload-notifications/client'
*/
export { ClientPushManager } from '../client/push-manager'
export type { PushSubscriptionData } from '../client/push-manager'
// Service worker utilities
export const serviceWorkerCode = `
/**
* Service Worker for Web Push Notifications
* This code should be served as /sw.js or similar
*/
declare const self: ServiceWorkerGlobalScope
interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
timestamp: number
}
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker')
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker')
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
if (!event.data) return
try {
const payload: NotificationPayload = event.data.json()
const { title, body, ...options } = payload
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: options.icon || '/icon-192x192.png',
badge: options.badge || '/badge-72x72.png',
image: options.image,
data: options.data,
actions: options.actions,
tag: options.tag,
requireInteraction: options.requireInteraction || false,
timestamp: options.timestamp || Date.now(),
vibrate: [200, 100, 200],
renotify: true,
})
)
} catch (error) {
console.error('[SW] Error processing push notification:', error)
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const data = event.notification.data || {}
if (event.action) {
switch (event.action) {
case 'view':
if (data.url) {
event.waitUntil(self.clients.openWindow(data.url))
}
break
case 'dismiss':
break
}
} else {
const urlToOpen = data.url || '/'
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((windowClients) => {
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus()
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(urlToOpen)
}
})
)
}
})
self.addEventListener('notificationclose', (event) => {
const data = event.notification.data || {}
if (data.trackClose) {
fetch('/api/push-notifications/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'close',
notificationId: data.id,
timestamp: Date.now(),
}),
}).catch(console.error)
}
})
export {}
`
// React types (conditional)
interface ReactHooks {
useState: any
useEffect: any
}
// Try to import React hooks
let ReactHooks: ReactHooks | null = null
try {
const React = require('react')
ReactHooks = {
useState: React.useState,
useEffect: React.useEffect
}
} catch {
// React not available
}
/**
* React hook for managing push notifications
* Only works if React is available in the environment
*/
export function usePushNotifications(vapidPublicKey: string) {
if (!ReactHooks) {
throw new Error('React is not available. Make sure React is installed to use this hook.')
}
const [isSupported, setIsSupported] = ReactHooks.useState(false)
const [isSubscribed, setIsSubscribed] = ReactHooks.useState(false)
const [permission, setPermission] = ReactHooks.useState('default' as NotificationPermission)
const [pushManager, setPushManager] = ReactHooks.useState(null)
ReactHooks.useEffect(() => {
const { ClientPushManager } = require('../client/push-manager')
const manager = new ClientPushManager(vapidPublicKey)
setPushManager(manager)
setIsSupported(manager.isSupported())
setPermission(manager.getPermissionStatus())
if (manager.isSupported()) {
manager.isSubscribed().then(setIsSubscribed)
}
}, [vapidPublicKey])
const subscribe = async () => {
if (!pushManager) throw new Error('Push manager not initialized')
await pushManager.subscribe()
setIsSubscribed(true)
setPermission('granted')
}
const unsubscribe = async () => {
if (!pushManager) throw new Error('Push manager not initialized')
await pushManager.unsubscribe()
setIsSubscribed(false)
}
const requestPermission = async () => {
if (!pushManager) throw new Error('Push manager not initialized')
const newPermission = await pushManager.requestPermission()
setPermission(newPermission)
return newPermission
}
return {
isSupported,
isSubscribed,
permission,
subscribe,
unsubscribe,
requestPermission,
pushManager,
}
}

18
src/exports/rsc.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* React Server Component exports for the notifications plugin
* Import from '@xtr-dev/payload-notifications/rsc'
*/
export { WebPushManager } from '../utils/webPush'
export { createPushNotificationEndpoints } from '../endpoints/push-notifications'
export { createPushSubscriptionsCollection } from '../collections/push-subscriptions'
// Re-export types that are useful on the server side
export type {
WebPushConfig,
PushSubscription,
NotificationsPluginOptions,
NotificationRelationship,
NotificationCollectionConfig,
NotificationAccess,
} from '../types'

65
src/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { Config } from 'payload'
import type { NotificationsPluginOptions, NotificationsPlugin } from './types'
import { createNotificationsCollection } from './collections/notifications'
import { createPushSubscriptionsCollection } from './collections/push-subscriptions'
import { createPushNotificationEndpoints } from './endpoints/push-notifications'
/**
* PayloadCMS Notifications Plugin
*
* Adds a configurable notifications collection with support for:
* - Title and rich text message content
* - Recipient targeting
* - Read/unread status tracking
* - Configurable relationship attachments to any collection
*
* @param options Plugin configuration options
* @returns Configured PayloadCMS plugin
*/
export const notificationsPlugin: NotificationsPlugin = (options = {}) => {
return (config: Config): Config => {
// Create the notifications collection with provided options
const notificationsCollection = createNotificationsCollection(options)
// Add collections to the Payload config
const collections = config.collections || []
const newCollections = [
...collections,
notificationsCollection,
]
// Add push subscriptions collection if web push is enabled
if (options.webPush?.enabled) {
const pushSubscriptionsCollection = createPushSubscriptionsCollection(options.access, options)
newCollections.push(pushSubscriptionsCollection)
}
// Create push notification endpoints if web push is enabled
const endpoints = config.endpoints || []
const pushEndpoints = options.webPush?.enabled
? createPushNotificationEndpoints(options)
: []
return {
...config,
collections: newCollections,
endpoints: [
...endpoints,
...pushEndpoints,
],
}
}
}
// Export types for consumers
export type {
NotificationsPluginOptions,
NotificationRelationship,
NotificationCollectionConfig,
NotificationAccess,
NotificationChannel,
WebPushConfig,
} from './types'
// Default export
export default notificationsPlugin

125
src/types.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { Config, CollectionConfig, Access, Field } from 'payload'
import type * as webpush from 'web-push'
/**
* Configuration for a relationship field in the notifications collection
*/
export interface NotificationRelationship {
/** Field name in the attachments group */
name: string
/** Target collection slug to relate to */
relationTo: string
/** Label displayed in admin UI */
label?: string
/** Whether this relationship is required */
required?: boolean
/** Allow multiple selections */
hasMany?: boolean
}
/**
* Collection configuration options
*/
export interface NotificationCollectionConfig {
/** Collection slug */
slug?: string
/** Collection labels for admin UI */
labels?: {
singular?: string
plural?: string
}
}
/**
* Access control configuration for notifications collection
*/
export interface NotificationAccess {
read?: Access
create?: Access
update?: Access
delete?: Access
}
/**
* Web push subscription data structure
*/
export interface PushSubscription {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
/**
* Notification channel configuration
*/
export interface NotificationChannel {
/** Unique channel identifier */
id: string
/** Display name for the channel */
name: string
/** Channel description */
description?: string
/** Default enabled state for new subscriptions */
defaultEnabled?: boolean
}
/**
* Web push configuration options
*/
export interface WebPushConfig {
/** VAPID public key for push notifications */
vapidPublicKey: string
/** VAPID private key for push notifications */
vapidPrivateKey: string
/** Contact email for VAPID */
vapidSubject: string
/** Enable web push notifications */
enabled?: boolean
/** Custom push notification options */
options?: webpush.RequestOptions
/** Automatically send push notifications when notifications are created */
autoPush?: boolean
/** Custom notification content transformer */
transformNotification?: (notification: any) => {
title: string
body: string
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
}
/**
* Custom hook to find push subscriptions for a notification
* This allows implementing anonymous notifications or custom recipient logic
* If not provided, defaults to user-based subscriptions
*/
findSubscriptions?: (notification: any, payload: any) => Promise<any[]>
}
/**
* Main plugin configuration options
*/
export interface NotificationsPluginOptions {
/** Collection configuration */
collections?: NotificationCollectionConfig
/** Array of configurable relationship fields */
relationships?: NotificationRelationship[]
/** Custom access control functions */
access?: NotificationAccess
/** Additional custom fields to add to the collection */
fields?: Field[]
/** Web push notification configuration */
webPush?: WebPushConfig
/** Notification channels configuration */
channels?: NotificationChannel[]
}
/**
* Plugin function type
*/
export type NotificationsPlugin = (options?: NotificationsPluginOptions) => (config: Config) => Config

43
src/utils/buildFields.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { Field } from 'payload'
import type { NotificationRelationship } from '../types'
/**
* Builds relationship fields dynamically based on plugin configuration
* Creates individual relationship fields within an attachments group
*/
export function buildRelationshipFields(relationships: NotificationRelationship[]): Field[] {
if (!relationships || relationships.length === 0) {
return []
}
// Create individual relationship fields
const relationshipFields: Field[] = relationships.map((rel) => {
const baseField = {
name: rel.name,
type: 'relationship' as const,
relationTo: rel.relationTo,
label: rel.label || `Related ${rel.relationTo}`,
required: rel.required || false,
}
// Add hasMany conditionally to satisfy the type constraints
if (rel.hasMany) {
return {
...baseField,
hasMany: true,
}
}
return baseField
})
// Wrap relationship fields in a group called "attachments"
return [
{
name: 'attachments',
type: 'group',
label: 'Attachments',
fields: relationshipFields,
},
]
}

View File

@@ -0,0 +1,108 @@
/**
* Utility functions for extracting plain text from Payload rich text fields
*/
/**
* Extract plain text from Payload rich text content
* Supports both Lexical and Slate formats
*/
export function extractTextFromRichText(richText: any): string {
if (!richText) return ''
if (typeof richText === 'string') {
return richText
}
if (Array.isArray(richText)) {
return richText
.map(block => extractTextFromBlock(block))
.filter(Boolean)
.join(' ')
}
return ''
}
/**
* Extract text from a rich text block
*/
function extractTextFromBlock(block: any): string {
if (!block) return ''
// Handle Lexical format (Payload 3.x default)
if (block.type === 'paragraph' || block.type === 'heading') {
return extractTextFromChildren(block.children || [])
}
// Handle direct children array
if (block.children && Array.isArray(block.children)) {
return extractTextFromChildren(block.children)
}
// Handle text nodes directly
if (typeof block.text === 'string') {
return block.text
}
// Handle Slate format (legacy)
if (block.type === 'p' || block.type === 'h1' || block.type === 'h2') {
return extractTextFromChildren(block.children || [])
}
return ''
}
/**
* Extract text from children array
*/
function extractTextFromChildren(children: any[]): string {
if (!Array.isArray(children)) return ''
return children
.map(child => {
if (typeof child === 'string') return child
if (typeof child.text === 'string') return child.text
if (child.children) return extractTextFromChildren(child.children)
return ''
})
.join('')
}
/**
* Truncate text to a maximum length with ellipsis
*/
export function truncateText(text: string, maxLength: number = 100): string {
if (!text || text.length <= maxLength) return text
return text.substring(0, maxLength - 3) + '...'
}
/**
* Default notification content transformer
* Converts a notification document to push notification format
*/
export function defaultNotificationTransformer(notification: any) {
const title = notification.title || 'New Notification'
// Extract plain text from rich text message
const messageText = extractTextFromRichText(notification.message)
const body = truncateText(messageText, 120) || 'You have a new notification'
return {
title,
body,
icon: '/icons/notification-icon.png',
badge: '/icons/notification-badge.png',
image: undefined, // Optional image property
data: {
notificationId: notification.id,
url: `/admin/collections/notifications/${notification.id}`,
createdAt: notification.createdAt,
},
actions: [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
],
tag: `notification-${notification.id}`,
requireInteraction: false,
}
}

271
src/utils/webPush.ts Normal file
View File

@@ -0,0 +1,271 @@
import webpush from 'web-push'
import type { Payload } from 'payload'
import type { WebPushConfig, PushSubscription } from '../types'
/**
* Web Push utility class for handling push notifications
*/
export class WebPushManager {
private config: WebPushConfig
private payload: Payload
private initialized = false
constructor(config: WebPushConfig, payload: Payload) {
this.config = config
this.payload = payload
}
/**
* Initialize web-push with VAPID details
*/
public init(): void {
if (this.initialized) return
webpush.setVapidDetails(
this.config.vapidSubject,
this.config.vapidPublicKey,
this.config.vapidPrivateKey
)
this.initialized = true
}
/**
* Send push notification to a specific subscription
*/
public async sendNotification(
subscription: PushSubscription,
payload: string | Buffer,
options?: webpush.RequestOptions
): Promise<webpush.SendResult> {
this.init()
const pushSubscription = {
endpoint: subscription.endpoint,
keys: subscription.keys,
}
const requestOptions = {
...this.config.options,
...options,
}
return webpush.sendNotification(pushSubscription, payload, requestOptions)
}
/**
* Send push notification to all active subscriptions for a recipient
*/
public async sendToRecipient(
recipientId: string,
title: string,
body: string,
options?: {
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
channel?: string
recipientType?: 'user' | 'text' | 'email'
}
): Promise<Array<{ success: boolean; error?: any }>> {
// Build query conditions for filtering subscriptions based on recipient type
const whereConditions: any[] = [
{ isActive: { equals: true } },
]
// Add recipient filtering based on type
if (options?.recipientType === 'text' || options?.recipientType === 'email') {
// For text/email recipients, look for recipient field
whereConditions.push({ recipient: { equals: recipientId } })
} else {
// Default to user relationship
whereConditions.push({ user: { equals: recipientId } })
}
// Add channel filtering if specified
if (options?.channel) {
whereConditions.push({
or: [
{ channels: { contains: options.channel } },
{ channels: { contains: 'all' } },
{ channels: { exists: false } }, // Handle subscriptions without channels field (backwards compatibility)
],
})
}
// Get all active push subscriptions for the user filtered by channel
const subscriptions = await this.payload.find({
collection: 'push-subscriptions',
where: {
and: whereConditions,
},
})
if (subscriptions.docs.length === 0) {
return []
}
const notificationPayload = JSON.stringify({
title,
body,
icon: options?.icon || '/icon-192x192.png',
badge: options?.badge || '/badge-72x72.png',
image: options?.image,
data: options?.data,
actions: options?.actions,
tag: options?.tag,
requireInteraction: options?.requireInteraction || false,
timestamp: Date.now(),
})
const results = await Promise.allSettled(
subscriptions.docs.map(async (sub: any) => {
try {
const pushSub: PushSubscription = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
}
await this.sendNotification(pushSub, notificationPayload)
return { success: true }
} catch (error: any) {
// Handle expired/invalid subscriptions
if (error.statusCode === 410 || error.statusCode === 404) {
// Mark subscription as inactive
await this.payload.update({
collection: 'push-subscriptions',
id: sub.id,
data: { isActive: false },
})
}
return { success: false, error }
}
})
)
return results.map((result) =>
result.status === 'fulfilled'
? result.value
: { success: false, error: result.reason }
)
}
/**
* Send push notification to all active subscriptions for a user (backward compatibility)
* @deprecated Use sendToRecipient instead
*/
public async sendToUser(
userId: string,
title: string,
body: string,
options?: {
icon?: string
badge?: string
image?: string
data?: any
actions?: Array<{ action: string; title: string; icon?: string }>
tag?: string
requireInteraction?: boolean
channel?: string
}
): Promise<Array<{ success: boolean; error?: any }>> {
return this.sendToRecipient(userId, title, body, {
...options,
recipientType: 'user'
})
}
/**
* Subscribe a user to push notifications
*/
public async subscribe(
userId: string,
subscription: PushSubscription,
userAgent?: string,
channels?: string[]
): Promise<void> {
try {
// Check if subscription already exists
const existing = await this.payload.find({
collection: 'push-subscriptions',
where: {
endpoint: { equals: subscription.endpoint },
},
limit: 1,
})
if (existing.docs.length > 0) {
// Update existing subscription
await this.payload.update({
collection: 'push-subscriptions',
id: existing.docs[0].id,
data: {
user: userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
channels: channels || ['all'],
isActive: true,
},
})
} else {
// Create new subscription
await this.payload.create({
collection: 'push-subscriptions',
data: {
user: userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
channels: channels || ['all'],
isActive: true,
},
})
}
} catch (error) {
console.error('Failed to save push subscription:', error)
throw error
}
}
/**
* Unsubscribe a user from push notifications
*/
public async unsubscribe(endpoint: string): Promise<void> {
try {
const subscription = await this.payload.find({
collection: 'push-subscriptions',
where: {
endpoint: { equals: endpoint },
},
limit: 1,
})
if (subscription.docs.length > 0) {
await this.payload.update({
collection: 'push-subscriptions',
id: subscription.docs[0].id,
data: { isActive: false },
})
}
} catch (error) {
console.error('Failed to unsubscribe:', error)
throw error
}
}
/**
* Get VAPID public key for client-side subscription
*/
public getVapidPublicKey(): string {
return this.config.vapidPublicKey
}
}

26
tsconfig.json Normal file
View File

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

25
vitest.config.js Normal file
View File

@@ -0,0 +1,25 @@
import path from 'path'
import { loadEnv } from 'payload/node'
import { fileURLToPath } from 'url'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default defineConfig(() => {
loadEnv(path.resolve(dirname, './dev'))
return {
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
test: {
environment: 'node',
hookTimeout: 30_000,
testTimeout: 30_000,
},
}
})