mirror of
https://github.com/xtr-dev/payload-notifications.git
synced 2025-12-10 10:53:23 +00:00
Compare commits
7 Commits
add-claude
...
v0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e2177d71 | |||
| 9f78d3ef72 | |||
| bb963a4da4 | |||
| 672be644cf | |||
| 553ccea7fa | |||
| 764462302e | |||
|
|
bcf11194eb |
43
.github/workflows/pr-version-check.yml
vendored
Normal file
43
.github/workflows/pr-version-check.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: PR Version Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
types: [opened, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR branch
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get PR branch package.json version
|
||||||
|
id: pr-version
|
||||||
|
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get main branch package.json version
|
||||||
|
id: main-version
|
||||||
|
run: |
|
||||||
|
git checkout main
|
||||||
|
echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compare versions
|
||||||
|
run: |
|
||||||
|
PR_VERSION="${{ steps.pr-version.outputs.version }}"
|
||||||
|
MAIN_VERSION="${{ steps.main-version.outputs.version }}"
|
||||||
|
|
||||||
|
echo "PR branch version: $PR_VERSION"
|
||||||
|
echo "Main branch version: $MAIN_VERSION"
|
||||||
|
|
||||||
|
if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
|
||||||
|
echo "❌ Version must be updated in package.json"
|
||||||
|
echo "Current version: $MAIN_VERSION"
|
||||||
|
echo "Please increment the version number before merging to main"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Version has been updated from $MAIN_VERSION to $PR_VERSION"
|
||||||
|
fi
|
||||||
49
.github/workflows/version-and-publish.yml
vendored
Normal file
49
.github/workflows/version-and-publish.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Publish to NPM
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Run build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Get package version
|
||||||
|
id: package-version
|
||||||
|
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create and push git tag
|
||||||
|
run: |
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "actions@github.com"
|
||||||
|
git tag -a "v${{ steps.package-version.outputs.version }}" -m "Release v${{ steps.package-version.outputs.version }}"
|
||||||
|
git push origin "v${{ steps.package-version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Publish to NPM
|
||||||
|
run: pnpm publish --access public --no-git-checks
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ yarn-error.log*
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
/dev.db
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# @xtr-dev/payload-notifications
|
# @xtr-dev/payload-notifications
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/@xtr-dev/payload-notifications)
|
||||||
|
|
||||||
A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items.
|
A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items.
|
||||||
|
|
||||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||||
|
|||||||
@@ -1,174 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import {ClientPushManager} from "../../../../src/client/push-manager"
|
||||||
// Enhanced demo implementation with real service worker registration
|
|
||||||
class DemoClientPushManager {
|
|
||||||
private vapidPublicKey: string
|
|
||||||
private serviceWorkerPath: string
|
|
||||||
private apiEndpoint: string
|
|
||||||
|
|
||||||
constructor(vapidPublicKey: string, options: { serviceWorkerPath?: string; apiEndpoint?: string } = {}) {
|
|
||||||
this.vapidPublicKey = vapidPublicKey
|
|
||||||
this.serviceWorkerPath = options.serviceWorkerPath || '/sw.js'
|
|
||||||
this.apiEndpoint = options.apiEndpoint || '/api/push-notifications'
|
|
||||||
}
|
|
||||||
|
|
||||||
public isSupported(): boolean {
|
|
||||||
if (typeof window === 'undefined') return false
|
|
||||||
return (
|
|
||||||
'serviceWorker' in navigator &&
|
|
||||||
'PushManager' in window &&
|
|
||||||
'Notification' in window
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public getPermissionStatus(): NotificationPermission {
|
|
||||||
if (typeof window === 'undefined' || typeof Notification === 'undefined') return 'default'
|
|
||||||
return Notification.permission
|
|
||||||
}
|
|
||||||
|
|
||||||
public async requestPermission(): Promise<NotificationPermission> {
|
|
||||||
if (!this.isSupported()) {
|
|
||||||
throw new Error('Push notifications are not supported')
|
|
||||||
}
|
|
||||||
return await Notification.requestPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
public async registerServiceWorker(): Promise<ServiceWorkerRegistration> {
|
|
||||||
if (!this.isSupported()) {
|
|
||||||
throw new Error('Service workers are not supported')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.register(this.serviceWorkerPath)
|
|
||||||
console.log('Service worker registered:', registration)
|
|
||||||
|
|
||||||
// Wait for service worker to be ready
|
|
||||||
await navigator.serviceWorker.ready
|
|
||||||
|
|
||||||
return registration
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Service worker registration failed:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async subscribe(): Promise<any> {
|
|
||||||
const permission = await this.requestPermission()
|
|
||||||
if (permission !== 'granted') {
|
|
||||||
throw new Error('Notification permission not granted')
|
|
||||||
}
|
|
||||||
|
|
||||||
const registration = await this.registerServiceWorker()
|
|
||||||
|
|
||||||
// For demo purposes, we'll simulate subscription without actual VAPID keys
|
|
||||||
// In production, you would use real VAPID keys here
|
|
||||||
try {
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('Push subscription:', subscription)
|
|
||||||
return {
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
keys: {
|
|
||||||
p256dh: this.arrayBufferToBase64(subscription.getKey('p256dh')!),
|
|
||||||
auth: this.arrayBufferToBase64(subscription.getKey('auth')!),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Real push subscription failed, simulating for demo:', error)
|
|
||||||
// Return simulated subscription for demo
|
|
||||||
return {
|
|
||||||
endpoint: 'demo-endpoint',
|
|
||||||
keys: { p256dh: 'demo-key', auth: 'demo-auth' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async isSubscribed(): Promise<boolean> {
|
|
||||||
if (!this.isSupported()) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (!registration) return false
|
|
||||||
|
|
||||||
const subscription = await registration.pushManager.getSubscription()
|
|
||||||
return subscription !== null
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unsubscribe(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (!registration) return
|
|
||||||
|
|
||||||
const subscription = await registration.pushManager.getSubscription()
|
|
||||||
if (subscription) {
|
|
||||||
await subscription.unsubscribe()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Unsubscribe failed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sendTestNotification(): Promise<void> {
|
|
||||||
// Send a test notification using the service worker
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (!registration) {
|
|
||||||
throw new Error('Service worker not registered')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate receiving a push message
|
|
||||||
if (registration.active) {
|
|
||||||
registration.active.postMessage({
|
|
||||||
type: 'TEST_NOTIFICATION',
|
|
||||||
payload: {
|
|
||||||
title: 'Test Notification',
|
|
||||||
body: 'This is a test notification from the demo!',
|
|
||||||
icon: '/icons/notification-icon.png',
|
|
||||||
badge: '/icons/notification-badge.png',
|
|
||||||
data: {
|
|
||||||
url: '/admin/collections/notifications',
|
|
||||||
notificationId: 'demo-' + Date.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also show a direct notification for testing
|
|
||||||
if (Notification.permission === 'granted') {
|
|
||||||
new Notification('Direct Test Notification', {
|
|
||||||
body: 'This notification was sent directly from JavaScript',
|
|
||||||
icon: '/icons/notification-icon.png',
|
|
||||||
tag: 'direct-test'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
|
||||||
|
|
||||||
const rawData = window.atob(base64)
|
|
||||||
const outputArray = new Uint8Array(rawData.length)
|
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
|
||||||
outputArray[i] = rawData.charCodeAt(i)
|
|
||||||
}
|
|
||||||
return outputArray
|
|
||||||
}
|
|
||||||
|
|
||||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer)
|
|
||||||
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
|
|
||||||
return window.btoa(binary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available channels (should match the configuration in payload.config.ts)
|
// Available channels (should match the configuration in payload.config.ts)
|
||||||
const AVAILABLE_CHANNELS = [
|
const AVAILABLE_CHANNELS = [
|
||||||
@@ -182,7 +15,7 @@ export default function DemoPage() {
|
|||||||
const [isSupported, setIsSupported] = useState(false)
|
const [isSupported, setIsSupported] = useState(false)
|
||||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||||
const [permission, setPermission] = useState<NotificationPermission>('default')
|
const [permission, setPermission] = useState<NotificationPermission>('default')
|
||||||
const [pushManager, setPushManager] = useState<DemoClientPushManager | null>(null)
|
const [pushManager, setPushManager] = useState<ClientPushManager | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selectedChannels, setSelectedChannels] = useState<string[]>(
|
const [selectedChannels, setSelectedChannels] = useState<string[]>(
|
||||||
AVAILABLE_CHANNELS.filter(channel => channel.defaultEnabled).map(channel => channel.id)
|
AVAILABLE_CHANNELS.filter(channel => channel.defaultEnabled).map(channel => channel.id)
|
||||||
@@ -191,7 +24,7 @@ export default function DemoPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use the real VAPID public key from environment
|
// Use the real VAPID public key from environment
|
||||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || 'BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A'
|
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || 'BNde-uFUkQB5BweFbOt_40Tn3xZahMop2JKT8kqRn4UqMMinieguHmVCTxwN_qfM-jZ0YFpVpIk3CWehlXcTl8A'
|
||||||
const manager = new DemoClientPushManager(vapidPublicKey)
|
const manager = new ClientPushManager(vapidPublicKey)
|
||||||
setPushManager(manager)
|
setPushManager(manager)
|
||||||
setIsSupported(manager.isSupported())
|
setIsSupported(manager.isSupported())
|
||||||
setPermission(manager.getPermissionStatus())
|
setPermission(manager.getPermissionStatus())
|
||||||
@@ -203,11 +36,11 @@ export default function DemoPage() {
|
|||||||
|
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
if (!pushManager) return
|
if (!pushManager) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const subscription = await pushManager.subscribe()
|
const subscription = await pushManager.subscribe(selectedChannels)
|
||||||
|
|
||||||
// Save the subscription to Payload's database using the plugin's API endpoint
|
// Save the subscription to Payload's database using the plugin's API endpoint
|
||||||
const response = await fetch('/api/push-notifications/subscribe', {
|
const response = await fetch('/api/push-notifications/subscribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -221,7 +54,6 @@ export default function DemoPage() {
|
|||||||
channels: selectedChannels,
|
channels: selectedChannels,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setIsSubscribed(true)
|
setIsSubscribed(true)
|
||||||
setPermission('granted')
|
setPermission('granted')
|
||||||
@@ -239,31 +71,12 @@ export default function DemoPage() {
|
|||||||
|
|
||||||
const handleUnsubscribe = async () => {
|
const handleUnsubscribe = async () => {
|
||||||
if (!pushManager) return
|
if (!pushManager) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await pushManager.unsubscribe()
|
await pushManager.unsubscribe()
|
||||||
|
setIsSubscribed(false)
|
||||||
// Remove the subscription from Payload's database
|
alert('Successfully unsubscribed from push notifications')
|
||||||
const response = await fetch('/api/push-notifications/unsubscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
user: 'customer@example.com', // Associate with the demo customer user
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setIsSubscribed(false)
|
|
||||||
alert('Successfully unsubscribed from push notifications')
|
|
||||||
} else {
|
|
||||||
const error = await response.text()
|
|
||||||
console.warn('Failed to remove subscription from database:', error)
|
|
||||||
setIsSubscribed(false)
|
|
||||||
alert('Unsubscribed from browser, but may still be in database')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to unsubscribe:', error)
|
console.error('Failed to unsubscribe:', error)
|
||||||
alert('Failed to unsubscribe from push notifications: ' + (error as Error).message)
|
alert('Failed to unsubscribe from push notifications: ' + (error as Error).message)
|
||||||
@@ -271,27 +84,13 @@ export default function DemoPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTestNotification = async () => {
|
|
||||||
if (!pushManager) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
await pushManager.sendTestNotification()
|
|
||||||
alert('Test notification sent! Check your browser notifications.')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send test notification:', error)
|
|
||||||
alert('Failed to send test notification: ' + (error as Error).message)
|
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
||||||
<h1>Payload Notifications Plugin Demo</h1>
|
<h1>Payload Notifications Plugin Demo</h1>
|
||||||
|
|
||||||
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
|
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ccc', borderRadius: '8px' }}>
|
||||||
<h2>🔔 Web Push Notifications</h2>
|
<h2>🔔 Web Push Notifications</h2>
|
||||||
|
|
||||||
{!isSupported ? (
|
{!isSupported ? (
|
||||||
<div style={{ color: 'red' }}>
|
<div style={{ color: 'red' }}>
|
||||||
❌ Push notifications are not supported in this browser
|
❌ Push notifications are not supported in this browser
|
||||||
@@ -300,7 +99,7 @@ export default function DemoPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p><strong>Status:</strong> {isSubscribed ? '✅ Subscribed' : '❌ Not subscribed'}</p>
|
<p><strong>Status:</strong> {isSubscribed ? '✅ Subscribed' : '❌ Not subscribed'}</p>
|
||||||
<p><strong>Permission:</strong> {permission}</p>
|
<p><strong>Permission:</strong> {permission}</p>
|
||||||
|
|
||||||
{!isSubscribed && (
|
{!isSubscribed && (
|
||||||
<div style={{ marginTop: '1rem', marginBottom: '1rem' }}>
|
<div style={{ marginTop: '1rem', marginBottom: '1rem' }}>
|
||||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem' }}>📢 Select Notification Channels</h3>
|
<h3 style={{ marginBottom: '0.5rem', fontSize: '1rem' }}>📢 Select Notification Channels</h3>
|
||||||
@@ -309,11 +108,11 @@ export default function DemoPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||||
{AVAILABLE_CHANNELS.map(channel => (
|
{AVAILABLE_CHANNELS.map(channel => (
|
||||||
<label
|
<label
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
border: '1px solid #e0e0e0',
|
border: '1px solid #e0e0e0',
|
||||||
@@ -347,10 +146,10 @@ export default function DemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
{!isSubscribed ? (
|
{!isSubscribed ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubscribe}
|
onClick={handleSubscribe}
|
||||||
disabled={loading || selectedChannels.length === 0}
|
disabled={loading || selectedChannels.length === 0}
|
||||||
style={{
|
style={{
|
||||||
@@ -367,7 +166,7 @@ export default function DemoPage() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleUnsubscribe}
|
onClick={handleUnsubscribe}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
style={{
|
||||||
@@ -382,21 +181,6 @@ export default function DemoPage() {
|
|||||||
>
|
>
|
||||||
{loading ? 'Unsubscribing...' : 'Disable Notifications'}
|
{loading ? 'Unsubscribing...' : 'Disable Notifications'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={handleTestNotification}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
backgroundColor: '#28a745',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: loading ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: loading ? 0.6 : 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? 'Sending...' : 'Send Test Notification'}
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -490,4 +274,4 @@ export default function DemoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { EmailAdapter, SendEmailOptions } from 'payload'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs all emails to stdout
|
|
||||||
*/
|
|
||||||
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
|
|
||||||
name: 'test-email-adapter',
|
|
||||||
defaultFromAddress: 'dev@payloadcms.com',
|
|
||||||
defaultFromName: 'Payload Test',
|
|
||||||
sendEmail: async (message) => {
|
|
||||||
const stringifiedTo = getStringifiedToAddress(message)
|
|
||||||
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
|
|
||||||
payload.logger.info({ content: message, msg: res })
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
|
|
||||||
let stringifiedTo: string | undefined
|
|
||||||
|
|
||||||
if (typeof message.to === 'string') {
|
|
||||||
stringifiedTo = message.to
|
|
||||||
} else if (Array.isArray(message.to)) {
|
|
||||||
stringifiedTo = message.to
|
|
||||||
.map((to: { address: string } | string) => {
|
|
||||||
if (typeof to === 'string') {
|
|
||||||
return to
|
|
||||||
} else if (to.address) {
|
|
||||||
return to.address
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
.join(', ')
|
|
||||||
} else if (message.to?.address) {
|
|
||||||
stringifiedTo = message.to.address
|
|
||||||
}
|
|
||||||
return stringifiedTo
|
|
||||||
}
|
|
||||||
@@ -68,10 +68,6 @@ export interface Config {
|
|||||||
blocks: {};
|
blocks: {};
|
||||||
collections: {
|
collections: {
|
||||||
users: User;
|
users: User;
|
||||||
orders: Order;
|
|
||||||
products: Product;
|
|
||||||
posts: Post;
|
|
||||||
media: Media;
|
|
||||||
notifications: Notification;
|
notifications: Notification;
|
||||||
'push-subscriptions': PushSubscription;
|
'push-subscriptions': PushSubscription;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
@@ -81,10 +77,6 @@ export interface Config {
|
|||||||
collectionsJoins: {};
|
collectionsJoins: {};
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
orders: OrdersSelect<false> | OrdersSelect<true>;
|
|
||||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
|
||||||
posts: PostsSelect<false> | PostsSelect<true>;
|
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
|
||||||
notifications: NotificationsSelect<false> | NotificationsSelect<true>;
|
notifications: NotificationsSelect<false> | NotificationsSelect<true>;
|
||||||
'push-subscriptions': PushSubscriptionsSelect<false> | PushSubscriptionsSelect<true>;
|
'push-subscriptions': PushSubscriptionsSelect<false> | PushSubscriptionsSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
@@ -143,79 +135,6 @@ export interface User {
|
|||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "orders".
|
|
||||||
*/
|
|
||||||
export interface Order {
|
|
||||||
id: string;
|
|
||||||
orderNumber: string;
|
|
||||||
customer: string | User;
|
|
||||||
status?: ('pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled') | null;
|
|
||||||
total: number;
|
|
||||||
products?: (string | Product)[] | null;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "products".
|
|
||||||
*/
|
|
||||||
export interface Product {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
price: number;
|
|
||||||
category?: ('electronics' | 'clothing' | 'books' | 'home-garden') | null;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "posts".
|
|
||||||
*/
|
|
||||||
export interface Post {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content?: {
|
|
||||||
root: {
|
|
||||||
type: string;
|
|
||||||
children: {
|
|
||||||
type: string;
|
|
||||||
version: number;
|
|
||||||
[k: string]: unknown;
|
|
||||||
}[];
|
|
||||||
direction: ('ltr' | 'rtl') | null;
|
|
||||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
|
||||||
indent: number;
|
|
||||||
version: number;
|
|
||||||
};
|
|
||||||
[k: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
author?: (string | null) | User;
|
|
||||||
publishedAt?: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "media".
|
|
||||||
*/
|
|
||||||
export interface Media {
|
|
||||||
id: string;
|
|
||||||
alt?: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
url?: string | null;
|
|
||||||
thumbnailURL?: string | null;
|
|
||||||
filename?: string | null;
|
|
||||||
mimeType?: string | null;
|
|
||||||
filesize?: number | null;
|
|
||||||
width?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
focalX?: number | null;
|
|
||||||
focalY?: number | null;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Manage user notifications and messaging
|
* Manage user notifications and messaging
|
||||||
*
|
*
|
||||||
@@ -247,9 +166,13 @@ export interface Notification {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* The user who should receive this notification
|
* The user who should receive this notification (optional if using custom recipient fields)
|
||||||
*/
|
*/
|
||||||
recipient: string | User;
|
recipient?: (string | null) | User;
|
||||||
|
/**
|
||||||
|
* The notification channel - only subscribers to this channel will receive the notification
|
||||||
|
*/
|
||||||
|
channel?: ('general' | 'orders' | 'products' | 'marketing') | null;
|
||||||
/**
|
/**
|
||||||
* Whether this notification has been read by the recipient
|
* Whether this notification has been read by the recipient
|
||||||
*/
|
*/
|
||||||
@@ -258,11 +181,6 @@ export interface Notification {
|
|||||||
* When this notification was marked as read
|
* When this notification was marked as read
|
||||||
*/
|
*/
|
||||||
readAt?: string | null;
|
readAt?: string | null;
|
||||||
attachments?: {
|
|
||||||
order?: (string | null) | Order;
|
|
||||||
product?: (string | Product)[] | null;
|
|
||||||
post?: (string | null) | Post;
|
|
||||||
};
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -294,6 +212,10 @@ export interface PushSubscription {
|
|||||||
* Browser/device information
|
* Browser/device information
|
||||||
*/
|
*/
|
||||||
userAgent?: string | null;
|
userAgent?: string | null;
|
||||||
|
/**
|
||||||
|
* Channels this subscription is subscribed to - leave empty for all notifications
|
||||||
|
*/
|
||||||
|
channels?: ('general' | 'orders' | 'products' | 'marketing')[] | null;
|
||||||
/**
|
/**
|
||||||
* Whether this subscription is still active
|
* Whether this subscription is still active
|
||||||
*/
|
*/
|
||||||
@@ -312,22 +234,6 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: string | User;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
|
||||||
relationTo: 'orders';
|
|
||||||
value: string | Order;
|
|
||||||
} | null)
|
|
||||||
| ({
|
|
||||||
relationTo: 'products';
|
|
||||||
value: string | Product;
|
|
||||||
} | null)
|
|
||||||
| ({
|
|
||||||
relationTo: 'posts';
|
|
||||||
value: string | Post;
|
|
||||||
} | null)
|
|
||||||
| ({
|
|
||||||
relationTo: 'media';
|
|
||||||
value: string | Media;
|
|
||||||
} | null)
|
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'notifications';
|
relationTo: 'notifications';
|
||||||
value: string | Notification;
|
value: string | Notification;
|
||||||
@@ -396,61 +302,6 @@ export interface UsersSelect<T extends boolean = true> {
|
|||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "orders_select".
|
|
||||||
*/
|
|
||||||
export interface OrdersSelect<T extends boolean = true> {
|
|
||||||
orderNumber?: T;
|
|
||||||
customer?: T;
|
|
||||||
status?: T;
|
|
||||||
total?: T;
|
|
||||||
products?: T;
|
|
||||||
updatedAt?: T;
|
|
||||||
createdAt?: T;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "products_select".
|
|
||||||
*/
|
|
||||||
export interface ProductsSelect<T extends boolean = true> {
|
|
||||||
name?: T;
|
|
||||||
description?: T;
|
|
||||||
price?: T;
|
|
||||||
category?: T;
|
|
||||||
updatedAt?: T;
|
|
||||||
createdAt?: T;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "posts_select".
|
|
||||||
*/
|
|
||||||
export interface PostsSelect<T extends boolean = true> {
|
|
||||||
title?: T;
|
|
||||||
content?: T;
|
|
||||||
author?: T;
|
|
||||||
publishedAt?: T;
|
|
||||||
updatedAt?: T;
|
|
||||||
createdAt?: T;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "media_select".
|
|
||||||
*/
|
|
||||||
export interface MediaSelect<T extends boolean = true> {
|
|
||||||
alt?: T;
|
|
||||||
updatedAt?: T;
|
|
||||||
createdAt?: T;
|
|
||||||
url?: T;
|
|
||||||
thumbnailURL?: T;
|
|
||||||
filename?: T;
|
|
||||||
mimeType?: T;
|
|
||||||
filesize?: T;
|
|
||||||
width?: T;
|
|
||||||
height?: T;
|
|
||||||
focalX?: T;
|
|
||||||
focalY?: T;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "notifications_select".
|
* via the `definition` "notifications_select".
|
||||||
@@ -459,15 +310,9 @@ export interface NotificationsSelect<T extends boolean = true> {
|
|||||||
title?: T;
|
title?: T;
|
||||||
message?: T;
|
message?: T;
|
||||||
recipient?: T;
|
recipient?: T;
|
||||||
|
channel?: T;
|
||||||
isRead?: T;
|
isRead?: T;
|
||||||
readAt?: T;
|
readAt?: T;
|
||||||
attachments?:
|
|
||||||
| T
|
|
||||||
| {
|
|
||||||
order?: T;
|
|
||||||
product?: T;
|
|
||||||
post?: T;
|
|
||||||
};
|
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
@@ -481,6 +326,7 @@ export interface PushSubscriptionsSelect<T extends boolean = true> {
|
|||||||
p256dh?: T;
|
p256dh?: T;
|
||||||
auth?: T;
|
auth?: T;
|
||||||
userAgent?: T;
|
userAgent?: T;
|
||||||
|
channels?: T;
|
||||||
isActive?: T;
|
isActive?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
|
||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import { testEmailAdapter } from './helpers/testEmailAdapter.ts'
|
import {seed} from "./seed"
|
||||||
import { seed } from './seed.ts'
|
|
||||||
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
|
import { notificationsPlugin } from '@xtr-dev/payload-notifications'
|
||||||
|
import {sqliteAdapter} from "@payloadcms/db-sqlite"
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -16,161 +16,128 @@ if (!process.env.ROOT_DIR) {
|
|||||||
process.env.ROOT_DIR = dirname
|
process.env.ROOT_DIR = dirname
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildConfigWithMemoryDB = async () => {
|
export default buildConfig({
|
||||||
if (!process.env.DATABASE_URI) {
|
admin: {
|
||||||
// Use a simple memory server instead of replica set for better stability
|
importMap: {
|
||||||
const { MongoMemoryServer } = await import('mongodb-memory-server')
|
baseDir: path.resolve(dirname),
|
||||||
const memoryDB = await MongoMemoryServer.create({
|
|
||||||
instance: {
|
|
||||||
dbName: 'payloadmemory',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
process.env.DATABASE_URI = memoryDB.getUri()
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildConfig({
|
|
||||||
admin: {
|
|
||||||
importMap: {
|
|
||||||
baseDir: path.resolve(dirname),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
collections: [
|
},
|
||||||
// Users collection with roles for authentication
|
collections: [
|
||||||
{
|
// Users collection with roles for authentication
|
||||||
slug: 'users',
|
{
|
||||||
auth: true,
|
slug: 'users',
|
||||||
admin: {
|
auth: true,
|
||||||
useAsTitle: 'email',
|
admin: {
|
||||||
},
|
useAsTitle: 'email',
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'role',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: 'Admin', value: 'admin' },
|
|
||||||
{ label: 'Customer', value: 'customer' },
|
|
||||||
],
|
|
||||||
defaultValue: 'customer',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'firstName',
|
|
||||||
type: 'text',
|
|
||||||
label: 'First Name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lastName',
|
|
||||||
type: 'text',
|
|
||||||
label: 'Last Name',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
fields: [
|
||||||
db: mongooseAdapter({
|
{
|
||||||
ensureIndexes: true,
|
name: 'role',
|
||||||
url: process.env.DATABASE_URI || '',
|
type: 'select',
|
||||||
}),
|
options: [
|
||||||
editor: lexicalEditor(),
|
{ label: 'Admin', value: 'admin' },
|
||||||
email: testEmailAdapter,
|
{ label: 'Customer', value: 'customer' },
|
||||||
onInit: async (payload) => {
|
],
|
||||||
await seed(payload)
|
defaultValue: 'customer',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firstName',
|
||||||
|
type: 'text',
|
||||||
|
label: 'First Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lastName',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Last Name',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
],
|
||||||
// Demo of the notifications plugin with relationships and channels
|
db: sqliteAdapter({
|
||||||
notificationsPlugin({
|
client: {
|
||||||
collections: {
|
url: process.env.DATABASE_URI || 'file:./dev.db',
|
||||||
slug: 'notifications',
|
},
|
||||||
labels: {
|
}),
|
||||||
singular: 'Notification',
|
editor: lexicalEditor(),
|
||||||
plural: 'Notifications'
|
onInit: async (payload) => {
|
||||||
}
|
await seed(payload)
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
// Demo of the notifications plugin with relationships and channels
|
||||||
|
notificationsPlugin({
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
name: 'General Notifications',
|
||||||
|
description: 'General updates and announcements',
|
||||||
},
|
},
|
||||||
channels: [
|
{
|
||||||
{
|
id: 'orders',
|
||||||
id: 'general',
|
name: 'Order Updates',
|
||||||
name: 'General Notifications',
|
description: 'Order status changes and shipping notifications',
|
||||||
description: 'General updates and announcements',
|
|
||||||
defaultEnabled: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'orders',
|
|
||||||
name: 'Order Updates',
|
|
||||||
description: 'Order status changes and shipping notifications',
|
|
||||||
defaultEnabled: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'products',
|
|
||||||
name: 'Product Updates',
|
|
||||||
description: 'New products, restocks, and price changes',
|
|
||||||
defaultEnabled: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'marketing',
|
|
||||||
name: 'Marketing & Promotions',
|
|
||||||
description: 'Special offers, sales, and promotional content',
|
|
||||||
defaultEnabled: false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
access: {
|
|
||||||
read: ({ req }: { req: any }) => Boolean(req.user),
|
|
||||||
create: ({ req }: { req: any }) => Boolean(req.user),
|
|
||||||
update: ({ req }: { req: any }) => Boolean(req.user),
|
|
||||||
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'),
|
|
||||||
},
|
},
|
||||||
webPush: {
|
{
|
||||||
enabled: true,
|
id: 'products',
|
||||||
autoPush: true, // Enable automatic push notifications
|
name: 'Product Updates',
|
||||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || 'BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L',
|
description: 'New products, restocks, and price changes',
|
||||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key-here',
|
},
|
||||||
vapidSubject: 'mailto:test@example.com',
|
{
|
||||||
// Custom notification transformer for demo
|
id: 'marketing',
|
||||||
transformNotification: (notification: any) => {
|
name: 'Marketing & Promotions',
|
||||||
const title = notification.title || 'New Notification'
|
description: 'Special offers, sales, and promotional content',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
webPush: {
|
||||||
|
enabled: true,
|
||||||
|
autoPush: true, // Enable automatic push notifications
|
||||||
|
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || 'BMrF5MbHcaEo6w4lPjG9m3BvONvFPfz7jLJ9t0F9yJGzSI3ZUHQj9fNUP7w2D8h1kI4x3YzJ1a4f0nS5g6t2F9L',
|
||||||
|
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || 'your-private-key-here',
|
||||||
|
vapidSubject: 'mailto:test@example.com',
|
||||||
|
// Custom notification transformer for demo
|
||||||
|
transformNotification: (notification: any) => {
|
||||||
|
const title = notification.title || 'New Notification'
|
||||||
|
|
||||||
// Extract text from rich text message
|
// Extract text from rich text message
|
||||||
let body = 'You have a new notification'
|
let body = 'You have a new notification'
|
||||||
if (notification.message && Array.isArray(notification.message)) {
|
if (notification.message && Array.isArray(notification.message)) {
|
||||||
const textParts: string[] = []
|
const textParts: string[] = []
|
||||||
notification.message.forEach((block: any) => {
|
notification.message.forEach((block: any) => {
|
||||||
if (block.children && Array.isArray(block.children)) {
|
if (block.children && Array.isArray(block.children)) {
|
||||||
block.children.forEach((child: any) => {
|
block.children.forEach((child: any) => {
|
||||||
if (child.text) textParts.push(child.text)
|
if (child.text) textParts.push(child.text)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
})
|
|
||||||
if (textParts.length > 0) {
|
|
||||||
body = textParts.join(' ').substring(0, 120) + (textParts.join(' ').length > 120 ? '...' : '')
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
body = textParts.join(' ').substring(0, 120) + (textParts.join(' ').length > 120 ? '...' : '')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `🔔 ${title}`,
|
title: `🔔 ${title}`,
|
||||||
body,
|
body,
|
||||||
icon: '/icons/notification-icon.png',
|
icon: '/icons/notification-icon.png',
|
||||||
badge: '/icons/notification-badge.png',
|
badge: '/icons/notification-badge.png',
|
||||||
data: {
|
data: {
|
||||||
notificationId: notification.id,
|
notificationId: notification.id,
|
||||||
url: `/admin/collections/notifications/${notification.id}`,
|
url: `/admin/collections/notifications/${notification.id}`,
|
||||||
createdAt: notification.createdAt,
|
createdAt: notification.createdAt,
|
||||||
},
|
},
|
||||||
actions: [
|
actions: [
|
||||||
{ action: 'view', title: 'View in Admin', icon: '/icons/view.png' },
|
{ action: 'view', title: 'View in Admin', icon: '/icons/view.png' },
|
||||||
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
|
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
|
||||||
],
|
],
|
||||||
tag: `notification-${notification.id}`,
|
tag: `notification-${notification.id}`,
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
],
|
}),
|
||||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
],
|
||||||
sharp,
|
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||||
typescript: {
|
sharp,
|
||||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
typescript: {
|
||||||
},
|
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
export default buildConfigWithMemoryDB()
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"rootDir": "./",
|
"rootDir": "../",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@payload-config": [
|
"@payload-config": [
|
||||||
"./payload.config.ts"
|
"./payload.config.ts"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-notifications",
|
"name": "@xtr-dev/payload-notifications",
|
||||||
"version": "1.0.0",
|
"version": "0.0.2",
|
||||||
"description": "A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items",
|
"description": "A PayloadCMS plugin that adds a configurable notifications collection for sending messages with titles, content, and attachable relationship items",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"lint:fix": "eslint ./src --fix",
|
"lint:fix": "eslint ./src --fix",
|
||||||
"prepublishOnly": "pnpm clean && pnpm build",
|
"prepublishOnly": "pnpm clean && pnpm build",
|
||||||
"test": "pnpm test:int && pnpm test:e2e",
|
"test": "echo \"No tests configured yet\"",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:int": "vitest"
|
"test:int": "vitest"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Client-side Push Notification Manager
|
* Client-side Push Notification Manager
|
||||||
* Handles subscription, permission requests, and communication with the server
|
* Handles subscription, permission requests, and communication with the server
|
||||||
*
|
*
|
||||||
* @description This module is designed to run in browser environments only
|
* @description This module is designed to run in browser environments only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ export interface PushSubscriptionData {
|
|||||||
const isBrowser = typeof window !== 'undefined'
|
const isBrowser = typeof window !== 'undefined'
|
||||||
|
|
||||||
export class ClientPushManager {
|
export class ClientPushManager {
|
||||||
private vapidPublicKey: string
|
private readonly vapidPublicKey: string
|
||||||
private serviceWorkerPath: string
|
private readonly serviceWorkerPath: string
|
||||||
private apiEndpoint: string
|
private readonly apiEndpoint: string
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
vapidPublicKey: string,
|
vapidPublicKey: string,
|
||||||
@@ -38,7 +38,7 @@ export class ClientPushManager {
|
|||||||
*/
|
*/
|
||||||
public isSupported(): boolean {
|
public isSupported(): boolean {
|
||||||
if (!isBrowser) return false
|
if (!isBrowser) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
'serviceWorker' in navigator &&
|
'serviceWorker' in navigator &&
|
||||||
'PushManager' in window &&
|
'PushManager' in window &&
|
||||||
@@ -62,8 +62,7 @@ export class ClientPushManager {
|
|||||||
throw new Error('Push notifications are not supported')
|
throw new Error('Push notifications are not supported')
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = await Notification.requestPermission()
|
return await Notification.requestPermission()
|
||||||
return permission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +86,7 @@ export class ClientPushManager {
|
|||||||
/**
|
/**
|
||||||
* Subscribe to push notifications
|
* Subscribe to push notifications
|
||||||
*/
|
*/
|
||||||
public async subscribe(): Promise<PushSubscriptionData> {
|
public async subscribe(channels: string[]): Promise<PushSubscriptionData> {
|
||||||
// Check support
|
// Check support
|
||||||
if (!this.isSupported()) {
|
if (!this.isSupported()) {
|
||||||
throw new Error('Push notifications are not supported')
|
throw new Error('Push notifications are not supported')
|
||||||
@@ -120,7 +119,7 @@ export class ClientPushManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send subscription to server
|
// Send subscription to server
|
||||||
await this.sendSubscriptionToServer(subscriptionData)
|
await this.sendSubscriptionToServer(subscriptionData, channels)
|
||||||
|
|
||||||
return subscriptionData
|
return subscriptionData
|
||||||
}
|
}
|
||||||
@@ -149,9 +148,9 @@ export class ClientPushManager {
|
|||||||
/**
|
/**
|
||||||
* Get current push subscription
|
* Get current push subscription
|
||||||
*/
|
*/
|
||||||
public async getSubscription(): Promise<PushSubscriptionData | null> {
|
public async getSubscription(): Promise<Omit<PushSubscriptionData, 'channels'> | null> {
|
||||||
if (!isBrowser || !('serviceWorker' in navigator)) return null
|
if (!isBrowser || !('serviceWorker' in navigator)) return null
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
return null
|
return null
|
||||||
@@ -183,7 +182,7 @@ export class ClientPushManager {
|
|||||||
/**
|
/**
|
||||||
* Send subscription data to server
|
* Send subscription data to server
|
||||||
*/
|
*/
|
||||||
private async sendSubscriptionToServer(subscription: PushSubscriptionData): Promise<void> {
|
private async sendSubscriptionToServer(subscription: PushSubscriptionData, channels: string[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiEndpoint}/subscribe`, {
|
const response = await fetch(`${this.apiEndpoint}/subscribe`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -192,6 +191,7 @@ export class ClientPushManager {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
subscription,
|
subscription,
|
||||||
|
channels,
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -251,4 +251,4 @@ export class ClientPushManager {
|
|||||||
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
|
const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join('')
|
||||||
return window.btoa(binary)
|
return window.btoa(binary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,32 @@
|
|||||||
import type { CollectionConfig, Field } from 'payload'
|
import type { CollectionConfig, Field } from 'payload'
|
||||||
import type { NotificationsPluginOptions, NotificationAccess } from '../types'
|
import type { NotificationsPluginOptions } from '../types'
|
||||||
import { buildRelationshipFields } from '../utils/buildFields'
|
|
||||||
import { WebPushManager } from '../utils/webPush'
|
import { WebPushManager } from '../utils/webPush'
|
||||||
import { defaultNotificationTransformer } from '../utils/richTextExtractor'
|
import { defaultNotificationTransformer } from '../utils/richTextExtractor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the notifications collection configuration
|
* Creates the notifications collection configuration
|
||||||
* Includes core fields plus dynamically generated relationship fields
|
|
||||||
*/
|
*/
|
||||||
export function createNotificationsCollection(options: NotificationsPluginOptions = {}): CollectionConfig {
|
export function createNotificationsCollection(options: NotificationsPluginOptions): CollectionConfig {
|
||||||
const {
|
const slug = 'notifications'
|
||||||
collections = {},
|
|
||||||
relationships = [],
|
|
||||||
access = {},
|
|
||||||
fields: customFields = [],
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const slug = collections.slug || 'notifications'
|
|
||||||
const labels = {
|
const labels = {
|
||||||
singular: collections.labels?.singular || 'Notification',
|
singular: 'Notification',
|
||||||
plural: collections.labels?.plural || 'Notifications',
|
plural: 'Notifications',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.channels.length === 0) {
|
||||||
|
throw new Error('No channels defined for notifications plugin')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default access control - authenticated users can read, admins can manage
|
// Default access control - authenticated users can read, admins can manage
|
||||||
const defaultAccess: NotificationAccess = {
|
const access: CollectionConfig['access'] = {
|
||||||
read: ({ req }: { req: any }) => Boolean(req.user),
|
read: ({ req }: { req: any }) => Boolean(req.user),
|
||||||
create: ({ req }: { req: any }) => Boolean(req.user),
|
create: ({ req }: { req: any }) => Boolean(req.user),
|
||||||
update: ({ req }: { req: any }) => Boolean(req.user),
|
update: ({ req }: { req: any }) => Boolean(req.user),
|
||||||
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'),
|
delete: ({ req }: { req: any }) => Boolean(req.user?.role === 'admin'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build channel field if channels are configured
|
|
||||||
const channelField: Field[] = options.channels && options.channels.length > 0 ? [
|
|
||||||
{
|
|
||||||
name: 'channel',
|
|
||||||
type: 'select',
|
|
||||||
label: 'Channel',
|
|
||||||
options: options.channels.map(channel => ({
|
|
||||||
label: channel.name,
|
|
||||||
value: channel.id,
|
|
||||||
})),
|
|
||||||
required: false,
|
|
||||||
admin: {
|
|
||||||
description: 'The notification channel - only subscribers to this channel will receive the notification',
|
|
||||||
position: 'sidebar',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] : []
|
|
||||||
|
|
||||||
// Default recipient field (relationship to users)
|
|
||||||
// Users can add custom recipient fields via the fields option and use findSubscriptions hook
|
|
||||||
const recipientField: Field = {
|
|
||||||
name: 'recipient',
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'users',
|
|
||||||
label: 'Recipient',
|
|
||||||
required: false,
|
|
||||||
admin: {
|
|
||||||
description: 'The user who should receive this notification (optional if using custom recipient fields)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build core fields
|
// Build core fields
|
||||||
const coreFields: Field[] = [
|
const allFields: Field[] = [
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -81,8 +45,30 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
description: 'The notification message content',
|
description: 'The notification message content',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recipientField,
|
{
|
||||||
...channelField,
|
name: 'recipient',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'users',
|
||||||
|
label: 'Recipient',
|
||||||
|
required: false,
|
||||||
|
admin: {
|
||||||
|
description: 'The user who should receive this notification (optional if using custom recipient fields)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
type: 'select',
|
||||||
|
label: 'Channel',
|
||||||
|
options: options.channels.map(channel => ({
|
||||||
|
label: channel.name,
|
||||||
|
value: channel.id,
|
||||||
|
})),
|
||||||
|
required: false,
|
||||||
|
admin: {
|
||||||
|
description: 'The notification channel - only subscribers to this channel will receive the notification',
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'isRead',
|
name: 'isRead',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
@@ -105,12 +91,6 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Build relationship fields
|
|
||||||
const relationshipFields = buildRelationshipFields(relationships)
|
|
||||||
|
|
||||||
// Combine all fields
|
|
||||||
const allFields = [...coreFields, ...relationshipFields, ...customFields]
|
|
||||||
|
|
||||||
const config: CollectionConfig = {
|
const config: CollectionConfig = {
|
||||||
slug,
|
slug,
|
||||||
labels,
|
labels,
|
||||||
@@ -120,12 +100,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
description: 'Manage user notifications and messaging',
|
description: 'Manage user notifications and messaging',
|
||||||
},
|
},
|
||||||
fields: allFields,
|
fields: allFields,
|
||||||
access: {
|
access,
|
||||||
read: access.read || defaultAccess.read!,
|
|
||||||
create: access.create || defaultAccess.create!,
|
|
||||||
update: access.update || defaultAccess.update!,
|
|
||||||
delete: access.delete || defaultAccess.delete!,
|
|
||||||
},
|
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +130,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
// Use custom hook to find subscriptions
|
// Use custom hook to find subscriptions
|
||||||
console.log('[Notifications Plugin] Using custom findSubscriptions hook')
|
console.log('[Notifications Plugin] Using custom findSubscriptions hook')
|
||||||
const subscriptions = await webPushConfig.findSubscriptions(doc, req.payload)
|
const subscriptions = await webPushConfig.findSubscriptions(doc, req.payload)
|
||||||
|
|
||||||
if (!subscriptions || subscriptions.length === 0) {
|
if (!subscriptions || subscriptions.length === 0) {
|
||||||
console.log('[Notifications Plugin] No subscriptions found via custom hook')
|
console.log('[Notifications Plugin] No subscriptions found via custom hook')
|
||||||
return
|
return
|
||||||
@@ -199,10 +174,10 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
return { success: false, error }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).then(results =>
|
).then(results =>
|
||||||
results.map((result) =>
|
results.map((result) =>
|
||||||
result.status === 'fulfilled'
|
result.status === 'fulfilled'
|
||||||
? result.value
|
? result.value
|
||||||
: { success: false, error: result.reason }
|
: { success: false, error: result.reason }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -213,9 +188,9 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let recipientId: string
|
let recipientId: string|number
|
||||||
|
|
||||||
if (typeof doc.recipient === 'string') {
|
if (typeof doc.recipient === 'string' || typeof doc.recipient === 'number') {
|
||||||
recipientId = doc.recipient
|
recipientId = doc.recipient
|
||||||
} else if (doc.recipient?.id) {
|
} else if (doc.recipient?.id) {
|
||||||
recipientId = doc.recipient.id
|
recipientId = doc.recipient.id
|
||||||
@@ -251,7 +226,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
console.log(`[Notifications Plugin] Push notification results: ${successful} sent, ${failed} failed`)
|
console.log(`[Notifications Plugin] Push notification results: ${successful} sent, ${failed} failed`)
|
||||||
|
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
console.warn('[Notifications Plugin] Some push notifications failed:',
|
console.warn('[Notifications Plugin] Some push notifications failed:',
|
||||||
results.filter(r => !r.success).map(r => r.error)
|
results.filter(r => !r.success).map(r => r.error)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -265,5 +240,7 @@ export function createNotificationsCollection(options: NotificationsPluginOption
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return options.collectionOverrides?.notifications ?
|
||||||
}
|
options.collectionOverrides.notifications(config) :
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import type { NotificationAccess, NotificationsPluginOptions } from '../types'
|
import type { NotificationsPluginOptions } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a collection to store web push subscriptions
|
* Creates a collection to store web push subscriptions
|
||||||
* Each user can have multiple subscriptions (different devices/browsers)
|
* Each user can have multiple subscriptions (different devices/browsers)
|
||||||
*/
|
*/
|
||||||
export function createPushSubscriptionsCollection(access: NotificationAccess = {}, options: NotificationsPluginOptions = {}): CollectionConfig {
|
export function createPushSubscriptionsCollection(options: NotificationsPluginOptions): CollectionConfig {
|
||||||
const defaultAccess: NotificationAccess = {
|
const access: CollectionConfig['access'] = {
|
||||||
read: ({ req }: { req: any }) => Boolean(req.user),
|
read: ({ req }: { req: any }) => Boolean(req.user),
|
||||||
create: ({ req }: { req: any }) => Boolean(req.user),
|
create: ({ req }: { req: any }) => Boolean(req.user),
|
||||||
update: ({ req }: { req: any }) => Boolean(req.user),
|
update: ({ req }: { req: any }) => Boolean(req.user),
|
||||||
delete: ({ req }: { req: any }) => Boolean(req.user),
|
delete: ({ req }: { req: any }) => Boolean(req.user),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const config: CollectionConfig = {
|
||||||
slug: 'push-subscriptions',
|
slug: 'push-subscriptions',
|
||||||
labels: {
|
labels: {
|
||||||
singular: 'Push Subscription',
|
singular: 'Push Subscription',
|
||||||
@@ -76,16 +76,11 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = {
|
|||||||
name: 'channels',
|
name: 'channels',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
label: 'Subscribed Channels',
|
label: 'Subscribed Channels',
|
||||||
options: options.channels && options.channels.length > 0
|
options: options.channels.map(channel => ({
|
||||||
? options.channels.map(channel => ({
|
label: channel.name,
|
||||||
label: channel.name,
|
value: channel.id,
|
||||||
value: channel.id,
|
})),
|
||||||
}))
|
|
||||||
: [{ label: 'All Notifications', value: 'all' }],
|
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
defaultValue: options.channels && options.channels.length > 0
|
|
||||||
? options.channels.filter(channel => channel.defaultEnabled !== false).map(channel => channel.id)
|
|
||||||
: ['all'],
|
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Channels this subscription is subscribed to - leave empty for all notifications',
|
description: 'Channels this subscription is subscribed to - leave empty for all notifications',
|
||||||
},
|
},
|
||||||
@@ -101,12 +96,7 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
access: {
|
access,
|
||||||
read: access.read || defaultAccess.read!,
|
|
||||||
create: access.create || defaultAccess.create!,
|
|
||||||
update: access.update || defaultAccess.update!,
|
|
||||||
delete: access.delete || defaultAccess.delete!,
|
|
||||||
},
|
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeChange: [
|
beforeChange: [
|
||||||
@@ -120,4 +110,7 @@ export function createPushSubscriptionsCollection(access: NotificationAccess = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
return options.collectionOverrides?.pushSubscriptions ?
|
||||||
|
options.collectionOverrides.pushSubscriptions(config) :
|
||||||
|
config
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
const webPushConfig = options.webPush
|
const webPushConfig = options.webPush
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Subscribe endpoint
|
// Subscribe endpoint
|
||||||
{
|
{
|
||||||
@@ -35,9 +35,8 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pushManager = new WebPushManager(webPushConfig, req.payload)
|
const pushManager = new WebPushManager(webPushConfig, req.payload)
|
||||||
|
|
||||||
await pushManager.subscribe(
|
await pushManager.subscribe(
|
||||||
String(req.user.id),
|
req.user.id,
|
||||||
subscription,
|
subscription,
|
||||||
userAgent,
|
userAgent,
|
||||||
channels
|
channels
|
||||||
@@ -247,4 +246,4 @@ export function createPushNotificationEndpoints(options: NotificationsPluginOpti
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,4 @@ export type {
|
|||||||
WebPushConfig,
|
WebPushConfig,
|
||||||
PushSubscription,
|
PushSubscription,
|
||||||
NotificationsPluginOptions,
|
NotificationsPluginOptions,
|
||||||
NotificationRelationship,
|
} from '../types'
|
||||||
NotificationCollectionConfig,
|
|
||||||
NotificationAccess,
|
|
||||||
} from '../types'
|
|
||||||
|
|||||||
31
src/index.ts
31
src/index.ts
@@ -4,23 +4,33 @@ import { createNotificationsCollection } from './collections/notifications'
|
|||||||
import { createPushSubscriptionsCollection } from './collections/push-subscriptions'
|
import { createPushSubscriptionsCollection } from './collections/push-subscriptions'
|
||||||
import { createPushNotificationEndpoints } from './endpoints/push-notifications'
|
import { createPushNotificationEndpoints } from './endpoints/push-notifications'
|
||||||
|
|
||||||
|
const defaultOptions: NotificationsPluginOptions = {
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
name: 'Default',
|
||||||
|
id: 'default',
|
||||||
|
description: 'Default channel',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PayloadCMS Notifications Plugin
|
* PayloadCMS Notifications Plugin
|
||||||
*
|
*
|
||||||
* Adds a configurable notifications collection with support for:
|
* Adds a configurable notifications collection with support for:
|
||||||
* - Title and rich text message content
|
* - Title and rich text message content
|
||||||
* - Recipient targeting
|
* - Recipient targeting
|
||||||
* - Read/unread status tracking
|
* - Read/unread status tracking
|
||||||
* - Configurable relationship attachments to any collection
|
* - Configurable relationship attachments to any collection
|
||||||
*
|
*
|
||||||
* @param options Plugin configuration options
|
* @param options Plugin configuration options
|
||||||
* @returns Configured PayloadCMS plugin
|
* @returns Configured PayloadCMS plugin
|
||||||
*/
|
*/
|
||||||
export const notificationsPlugin: NotificationsPlugin = (options = {}) => {
|
export const notificationsPlugin: NotificationsPlugin = (options = defaultOptions) => {
|
||||||
return (config: Config): Config => {
|
return (config: Config): Config => {
|
||||||
// Create the notifications collection with provided options
|
// Create the notifications collection with provided options
|
||||||
const notificationsCollection = createNotificationsCollection(options)
|
const notificationsCollection = createNotificationsCollection(options)
|
||||||
|
|
||||||
// Add collections to the Payload config
|
// Add collections to the Payload config
|
||||||
const collections = config.collections || []
|
const collections = config.collections || []
|
||||||
const newCollections = [
|
const newCollections = [
|
||||||
@@ -30,16 +40,16 @@ export const notificationsPlugin: NotificationsPlugin = (options = {}) => {
|
|||||||
|
|
||||||
// Add push subscriptions collection if web push is enabled
|
// Add push subscriptions collection if web push is enabled
|
||||||
if (options.webPush?.enabled) {
|
if (options.webPush?.enabled) {
|
||||||
const pushSubscriptionsCollection = createPushSubscriptionsCollection(options.access, options)
|
const pushSubscriptionsCollection = createPushSubscriptionsCollection(options)
|
||||||
newCollections.push(pushSubscriptionsCollection)
|
newCollections.push(pushSubscriptionsCollection)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create push notification endpoints if web push is enabled
|
// Create push notification endpoints if web push is enabled
|
||||||
const endpoints = config.endpoints || []
|
const endpoints = config.endpoints || []
|
||||||
const pushEndpoints = options.webPush?.enabled
|
const pushEndpoints = options.webPush?.enabled
|
||||||
? createPushNotificationEndpoints(options)
|
? createPushNotificationEndpoints(options)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
collections: newCollections,
|
collections: newCollections,
|
||||||
@@ -54,12 +64,9 @@ export const notificationsPlugin: NotificationsPlugin = (options = {}) => {
|
|||||||
// Export types for consumers
|
// Export types for consumers
|
||||||
export type {
|
export type {
|
||||||
NotificationsPluginOptions,
|
NotificationsPluginOptions,
|
||||||
NotificationRelationship,
|
|
||||||
NotificationCollectionConfig,
|
|
||||||
NotificationAccess,
|
|
||||||
NotificationChannel,
|
NotificationChannel,
|
||||||
WebPushConfig,
|
WebPushConfig,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
// Default export
|
// Default export
|
||||||
export default notificationsPlugin
|
export default notificationsPlugin
|
||||||
|
|||||||
66
src/types.ts
66
src/types.ts
@@ -1,44 +1,5 @@
|
|||||||
import type { Config, CollectionConfig, Access, Field } from 'payload'
|
import type {CollectionConfig, Config} from 'payload'
|
||||||
import type * as webpush from 'web-push'
|
import type {RequestOptions} from 'web-push'
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for a relationship field in the notifications collection
|
|
||||||
*/
|
|
||||||
export interface NotificationRelationship {
|
|
||||||
/** Field name in the attachments group */
|
|
||||||
name: string
|
|
||||||
/** Target collection slug to relate to */
|
|
||||||
relationTo: string
|
|
||||||
/** Label displayed in admin UI */
|
|
||||||
label?: string
|
|
||||||
/** Whether this relationship is required */
|
|
||||||
required?: boolean
|
|
||||||
/** Allow multiple selections */
|
|
||||||
hasMany?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collection configuration options
|
|
||||||
*/
|
|
||||||
export interface NotificationCollectionConfig {
|
|
||||||
/** Collection slug */
|
|
||||||
slug?: string
|
|
||||||
/** Collection labels for admin UI */
|
|
||||||
labels?: {
|
|
||||||
singular?: string
|
|
||||||
plural?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access control configuration for notifications collection
|
|
||||||
*/
|
|
||||||
export interface NotificationAccess {
|
|
||||||
read?: Access
|
|
||||||
create?: Access
|
|
||||||
update?: Access
|
|
||||||
delete?: Access
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web push subscription data structure
|
* Web push subscription data structure
|
||||||
@@ -61,8 +22,6 @@ export interface NotificationChannel {
|
|||||||
name: string
|
name: string
|
||||||
/** Channel description */
|
/** Channel description */
|
||||||
description?: string
|
description?: string
|
||||||
/** Default enabled state for new subscriptions */
|
|
||||||
defaultEnabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,7 +37,7 @@ export interface WebPushConfig {
|
|||||||
/** Enable web push notifications */
|
/** Enable web push notifications */
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/** Custom push notification options */
|
/** Custom push notification options */
|
||||||
options?: webpush.RequestOptions
|
options?: RequestOptions
|
||||||
/** Automatically send push notifications when notifications are created */
|
/** Automatically send push notifications when notifications are created */
|
||||||
autoPush?: boolean
|
autoPush?: boolean
|
||||||
/** Custom notification content transformer */
|
/** Custom notification content transformer */
|
||||||
@@ -93,8 +52,8 @@ export interface WebPushConfig {
|
|||||||
tag?: string
|
tag?: string
|
||||||
requireInteraction?: boolean
|
requireInteraction?: boolean
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Custom hook to find push subscriptions for a notification
|
* Custom hook to find push subscriptions for a notification
|
||||||
* This allows implementing anonymous notifications or custom recipient logic
|
* This allows implementing anonymous notifications or custom recipient logic
|
||||||
* If not provided, defaults to user-based subscriptions
|
* If not provided, defaults to user-based subscriptions
|
||||||
*/
|
*/
|
||||||
@@ -106,20 +65,17 @@ export interface WebPushConfig {
|
|||||||
*/
|
*/
|
||||||
export interface NotificationsPluginOptions {
|
export interface NotificationsPluginOptions {
|
||||||
/** Collection configuration */
|
/** Collection configuration */
|
||||||
collections?: NotificationCollectionConfig
|
collectionOverrides?: {
|
||||||
/** Array of configurable relationship fields */
|
notifications: (config: CollectionConfig) => CollectionConfig
|
||||||
relationships?: NotificationRelationship[]
|
pushSubscriptions: (config: CollectionConfig) => CollectionConfig
|
||||||
/** Custom access control functions */
|
}
|
||||||
access?: NotificationAccess
|
|
||||||
/** Additional custom fields to add to the collection */
|
|
||||||
fields?: Field[]
|
|
||||||
/** Web push notification configuration */
|
/** Web push notification configuration */
|
||||||
webPush?: WebPushConfig
|
webPush?: WebPushConfig
|
||||||
/** Notification channels configuration */
|
/** Notification channels configuration */
|
||||||
channels?: NotificationChannel[]
|
channels: NotificationChannel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin function type
|
* Plugin function type
|
||||||
*/
|
*/
|
||||||
export type NotificationsPlugin = (options?: NotificationsPluginOptions) => (config: Config) => Config
|
export type NotificationsPlugin = (options?: NotificationsPluginOptions) => (config: Config) => Config
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { Field } from 'payload'
|
|
||||||
import type { NotificationRelationship } from '../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds relationship fields dynamically based on plugin configuration
|
|
||||||
* Creates individual relationship fields within an attachments group
|
|
||||||
*/
|
|
||||||
export function buildRelationshipFields(relationships: NotificationRelationship[]): Field[] {
|
|
||||||
if (!relationships || relationships.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create individual relationship fields
|
|
||||||
const relationshipFields: Field[] = relationships.map((rel) => {
|
|
||||||
const baseField = {
|
|
||||||
name: rel.name,
|
|
||||||
type: 'relationship' as const,
|
|
||||||
relationTo: rel.relationTo,
|
|
||||||
label: rel.label || `Related ${rel.relationTo}`,
|
|
||||||
required: rel.required || false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add hasMany conditionally to satisfy the type constraints
|
|
||||||
if (rel.hasMany) {
|
|
||||||
return {
|
|
||||||
...baseField,
|
|
||||||
hasMany: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseField
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wrap relationship fields in a group called "attachments"
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: 'attachments',
|
|
||||||
type: 'group',
|
|
||||||
label: 'Attachments',
|
|
||||||
fields: relationshipFields,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,7 @@ export class WebPushManager {
|
|||||||
* Send push notification to all active subscriptions for a recipient
|
* Send push notification to all active subscriptions for a recipient
|
||||||
*/
|
*/
|
||||||
public async sendToRecipient(
|
public async sendToRecipient(
|
||||||
recipientId: string,
|
recipientId: string|number,
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -151,8 +151,8 @@ export class WebPushManager {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return results.map((result) =>
|
return results.map((result) =>
|
||||||
result.status === 'fulfilled'
|
result.status === 'fulfilled'
|
||||||
? result.value
|
? result.value
|
||||||
: { success: false, error: result.reason }
|
: { success: false, error: result.reason }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ export class WebPushManager {
|
|||||||
* Subscribe a user to push notifications
|
* Subscribe a user to push notifications
|
||||||
*/
|
*/
|
||||||
public async subscribe(
|
public async subscribe(
|
||||||
userId: string,
|
userId: string | number,
|
||||||
subscription: PushSubscription,
|
subscription: PushSubscription,
|
||||||
userAgent?: string,
|
userAgent?: string,
|
||||||
channels?: string[]
|
channels?: string[]
|
||||||
@@ -211,7 +211,7 @@ export class WebPushManager {
|
|||||||
p256dh: subscription.keys.p256dh,
|
p256dh: subscription.keys.p256dh,
|
||||||
auth: subscription.keys.auth,
|
auth: subscription.keys.auth,
|
||||||
userAgent,
|
userAgent,
|
||||||
channels: channels || ['all'],
|
channels,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -225,7 +225,7 @@ export class WebPushManager {
|
|||||||
p256dh: subscription.keys.p256dh,
|
p256dh: subscription.keys.p256dh,
|
||||||
auth: subscription.keys.auth,
|
auth: subscription.keys.auth,
|
||||||
userAgent,
|
userAgent,
|
||||||
channels: channels || ['all'],
|
channels,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -268,4 +268,4 @@ export class WebPushManager {
|
|||||||
public getVapidPublicKey(): string {
|
public getVapidPublicKey(): string {
|
||||||
return this.config.vapidPublicKey
|
return this.config.vapidPublicKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user