mirror of
https://github.com/xtr-dev/payload-notifications.git
synced 2025-12-08 00:33:23 +00:00
Add initial plugin implementation and development setup
This commit is contained in:
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": false
|
||||
}
|
||||
24
.swcrc
Normal file
24
.swcrc
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
24
.vscode/launch.json
vendored
Normal file
24
.vscode/launch.json
vendored
Normal 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
40
.vscode/settings.json
vendored
Normal 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
77
CLAUDE.md
Normal 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
517
README.md
Normal 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
13
dev/.env.example
Normal 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
220
dev/README.md
Normal 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
493
dev/app/(app)/demo/page.tsx
Normal 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
92
dev/app/(app)/layout.tsx
Normal 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
126
dev/app/(app)/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal 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
|
||||
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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
|
||||
49
dev/app/(payload)/admin/importMap.js
Normal file
49
dev/app/(payload)/admin/importMap.js
Normal 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
|
||||
}
|
||||
19
dev/app/(payload)/api/[...slug]/route.ts
Normal file
19
dev/app/(payload)/api/[...slug]/route.ts
Normal 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)
|
||||
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal file
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal 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)
|
||||
8
dev/app/(payload)/api/graphql/route.ts
Normal file
8
dev/app/(payload)/api/graphql/route.ts
Normal 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)
|
||||
0
dev/app/(payload)/custom.scss
Normal file
0
dev/app/(payload)/custom.scss
Normal file
32
dev/app/(payload)/layout.tsx
Normal file
32
dev/app/(payload)/layout.tsx
Normal 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
14
dev/app/layout.tsx
Normal 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
12
dev/app/my-route/route.ts
Normal 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
15
dev/e2e.spec.ts
Normal 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()
|
||||
})
|
||||
4
dev/helpers/credentials.ts
Normal file
4
dev/helpers/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const devUser = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
}
|
||||
38
dev/helpers/testEmailAdapter.ts
Normal file
38
dev/helpers/testEmailAdapter.ts
Normal 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
52
dev/int.spec.ts
Normal 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
5
dev/next-env.d.ts
vendored
Normal 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
21
dev/next.config.mjs
Normal 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
531
dev/payload-types.ts
Normal 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
176
dev/payload.config.ts
Normal 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()
|
||||
27
dev/public/icons/README.md
Normal file
27
dev/public/icons/README.md
Normal 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
197
dev/public/sw.js
Normal 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
183
dev/seed.ts
Normal 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
43
dev/tsconfig.json
Normal 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
46
eslint.config.js
Normal 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
120
package.json
Normal 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
46
playwright.config.js
Normal 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
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
254
src/client/push-manager.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
132
src/client/service-worker.ts
Normal file
132
src/client/service-worker.ts
Normal 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 {}
|
||||
269
src/collections/notifications.ts
Normal file
269
src/collections/notifications.ts
Normal 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
|
||||
}
|
||||
123
src/collections/push-subscriptions.ts
Normal file
123
src/collections/push-subscriptions.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
250
src/endpoints/push-notifications.ts
Normal file
250
src/endpoints/push-notifications.ts
Normal 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
190
src/exports/client.ts
Normal 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
18
src/exports/rsc.ts
Normal 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
65
src/index.ts
Normal 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
125
src/types.ts
Normal 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
43
src/utils/buildFields.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
108
src/utils/richTextExtractor.ts
Normal file
108
src/utils/richTextExtractor.ts
Normal 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
271
src/utils/webPush.ts
Normal 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
26
tsconfig.json
Normal 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
25
vitest.config.js
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user