feat: implement complete web push notification system
- Add self-hosted push notifications using VAPID (no external services) - Create PushSubscription model in database - Implement server-side push logic with web-push library - Add notification helpers for list events (items, invitations, collaborators) - Create NotificationSettings UI component for profile page - Add usePushNotifications React hook for subscription management - Create API routes for subscription management and VAPID key - Update service worker with push event handlers - Integrate notifications into item actions (add, remove, complete) - Integrate notifications into sharing actions (invite, accept, remove) - Add VAPID key generation script - Add comprehensive documentation and setup guides Notifications are sent when: - Items are added/removed/completed in shared lists - Users are invited to lists - Invitations are accepted - Collaborators are removed from lists Works on iOS 16.4+ (when installed to home screen), Android, and desktop browsers.
This commit is contained in:
@@ -7,3 +7,9 @@ NEXTAUTH_URL=http://localhost:3000
|
||||
# Generate a secure secret with: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
|
||||
AUTH_SECRET=your-secret-key-here-please-change-in-production
|
||||
|
||||
# Web Push Notifications (VAPID)
|
||||
# Generate keys with: npm run vapid:generate
|
||||
VAPID_PUBLIC_KEY=your-vapid-public-key-here
|
||||
VAPID_PRIVATE_KEY=your-vapid-private-key-here
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
|
||||
264
PUSH_NOTIFICATIONS_COMPLETE.md
Normal file
264
PUSH_NOTIFICATIONS_COMPLETE.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Web Push Notifications - Complete Implementation ✅
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
A complete, self-hosted Web Push notification system for your PWA shopping list app.
|
||||
|
||||
## 📦 Files Created
|
||||
|
||||
### Core Implementation
|
||||
- `lib/push-notifications.ts` - Server-side push notification logic with VAPID
|
||||
- `lib/notification-helpers.ts` - Helper functions for specific notification events
|
||||
- `hooks/usePushNotifications.ts` - React hook for managing subscriptions
|
||||
- `components/NotificationSettings.tsx` - UI component for enabling/disabling notifications
|
||||
|
||||
### API Routes
|
||||
- `app/api/push/vapid-key/route.ts` - Returns public VAPID key
|
||||
- `app/api/push/subscribe/route.ts` - Saves push subscriptions
|
||||
- `app/api/push/unsubscribe/route.ts` - Removes push subscriptions
|
||||
|
||||
### Database
|
||||
- Updated `prisma/schema.prisma` - Added `PushSubscription` model
|
||||
|
||||
### Service Worker
|
||||
- Updated `public/sw.js` - Added push event handlers
|
||||
|
||||
### Scripts & Config
|
||||
- `scripts/generate-vapid-keys.js` - Generate VAPID keys
|
||||
- Updated `package.json` - Added web-push dependency and npm script
|
||||
- Updated `.env.example` - Added VAPID key placeholders
|
||||
|
||||
### Documentation
|
||||
- `PUSH_NOTIFICATIONS_QUICK_START.md` - 5-minute setup guide
|
||||
- `PUSH_NOTIFICATIONS_SETUP.md` - Complete setup and configuration
|
||||
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` - Code integration examples
|
||||
- `PUSH_NOTIFICATIONS_GUIDE.md` - Architecture and testing guide
|
||||
- `PUSH_NOTIFICATIONS_COMPLETE.md` - This file
|
||||
|
||||
## 🎯 How It Works
|
||||
|
||||
### The Push Notification Server
|
||||
|
||||
**You don't need an external service!** This implementation uses **VAPID** (Voluntary Application Server Identification), which is a standard protocol that allows your own server to send push notifications directly to browsers.
|
||||
|
||||
Here's the flow:
|
||||
|
||||
1. **Generate VAPID Keys** (one-time setup)
|
||||
- Run `npm run vapid:generate`
|
||||
- These are like JWT secrets - a public/private key pair
|
||||
- Public key: shared with browsers
|
||||
- Private key: kept secret on your server
|
||||
|
||||
2. **User Subscribes**
|
||||
- User clicks "Enable" in NotificationSettings
|
||||
- Browser creates a unique push subscription (includes endpoint URL)
|
||||
- Subscription saved to your database
|
||||
|
||||
3. **Event Occurs**
|
||||
- Someone adds an item to a shared list
|
||||
- Your server action calls `notifyItemAdded()`
|
||||
- Function looks up all subscriptions for list members
|
||||
- Sends push notification using `web-push` library
|
||||
|
||||
4. **Browser Receives**
|
||||
- Browser's push service receives the notification
|
||||
- Service worker wakes up and displays it
|
||||
- User sees notification even if app is closed
|
||||
|
||||
### The "Server" is Just Your App
|
||||
|
||||
The push notification "server" is simply:
|
||||
- The `web-push` npm package (installed in your app)
|
||||
- Your VAPID keys (in your .env file)
|
||||
- Your existing Next.js API routes
|
||||
|
||||
No external service, no additional infrastructure needed!
|
||||
|
||||
## 🚀 Setup Instructions
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 2: Generate VAPID Keys
|
||||
```bash
|
||||
npm run vapid:generate
|
||||
```
|
||||
|
||||
Copy the output to your `.env` file:
|
||||
```env
|
||||
VAPID_PUBLIC_KEY=BNxxx...
|
||||
VAPID_PRIVATE_KEY=xxx...
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
```
|
||||
|
||||
### Step 3: Update Database
|
||||
```bash
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
### Step 4: Add UI Component
|
||||
|
||||
In `app/profile/page.tsx`:
|
||||
```typescript
|
||||
import NotificationSettings from '@/components/NotificationSettings';
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Your existing content */}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Integrate Notifications
|
||||
|
||||
In your action files (see `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` for details):
|
||||
|
||||
```typescript
|
||||
import { notifyItemAdded } from '@/lib/notification-helpers';
|
||||
|
||||
// After creating an item:
|
||||
await notifyItemAdded(listId, item.title, session.user.id);
|
||||
```
|
||||
|
||||
## 📱 Notification Events
|
||||
|
||||
| Event | Function | When |
|
||||
|-------|----------|------|
|
||||
| Item added | `notifyItemAdded()` | Someone adds item to shared list |
|
||||
| Item removed | `notifyItemRemoved()` | Someone removes item |
|
||||
| Item completed | `notifyItemCompleted()` | Someone marks item complete |
|
||||
| List invitation | `notifyListInvitation()` | Someone invites you |
|
||||
| Invitation accepted | `notifyInvitationAccepted()` | Someone accepts your invite |
|
||||
| Removed from list | `notifyRemovedFromList()` | Owner removes you |
|
||||
|
||||
## 🌐 Browser Support
|
||||
|
||||
| Platform | Support | Notes |
|
||||
|----------|---------|-------|
|
||||
| Chrome Desktop | ✅ Full | Works perfectly |
|
||||
| Chrome Android | ✅ Full | Works perfectly |
|
||||
| Firefox Desktop | ✅ Full | Works perfectly |
|
||||
| Firefox Android | ✅ Full | Works perfectly |
|
||||
| Safari macOS | ✅ 16.4+ | Works when installed |
|
||||
| Safari iOS | ✅ 16.4+ | **Must be installed to home screen** |
|
||||
| Edge | ✅ Full | Chromium-based |
|
||||
|
||||
### iOS Requirements
|
||||
- iOS 16.4 or later
|
||||
- Must use "Add to Home Screen" (won't work in Safari browser)
|
||||
- Requires HTTPS in production
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- VAPID private key must be kept secret (like JWT secret)
|
||||
- Subscriptions are tied to authenticated users
|
||||
- Expired subscriptions automatically cleaned up
|
||||
- No cross-user notification access
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Desktop (Chrome)
|
||||
1. Start dev server: `npm run dev`
|
||||
2. Open DevTools → Application → Service Workers
|
||||
3. Enable notifications in profile
|
||||
4. Trigger an action (add item, etc.)
|
||||
5. See notification appear
|
||||
|
||||
### iOS (Real Device)
|
||||
1. Deploy with HTTPS
|
||||
2. Open in Safari
|
||||
3. Share → Add to Home Screen
|
||||
4. Open installed app
|
||||
5. Enable notifications
|
||||
6. Test from another device
|
||||
|
||||
## 📊 Database Schema
|
||||
|
||||
New table added:
|
||||
```prisma
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
endpoint String @db.Text
|
||||
p256dh String @db.Text
|
||||
auth String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(...)
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Change Notification Content
|
||||
Edit `lib/notification-helpers.ts` to customize titles, bodies, icons.
|
||||
|
||||
### Add Per-List Preferences
|
||||
1. Add `notificationsEnabled` to `Collaborator` model
|
||||
2. Check this field before sending notifications
|
||||
3. Add UI toggle in list settings
|
||||
|
||||
### Add Notification Sounds
|
||||
Update service worker push event:
|
||||
```javascript
|
||||
const options = {
|
||||
...existing,
|
||||
silent: false,
|
||||
sound: '/notification-sound.mp3',
|
||||
};
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Push notifications not configured"
|
||||
- Verify VAPID keys are in `.env`
|
||||
- Restart dev server
|
||||
|
||||
### Notifications not appearing
|
||||
- Check browser permission (must be "granted")
|
||||
- Verify service worker is active
|
||||
- Check console for errors
|
||||
- Ensure HTTPS (or localhost)
|
||||
|
||||
### iOS not working
|
||||
- Check iOS version (16.4+)
|
||||
- Must be installed to home screen
|
||||
- Check Settings → [App] → Notifications
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- Notifications sent asynchronously (non-blocking)
|
||||
- Multiple devices per user supported
|
||||
- Automatic cleanup of expired subscriptions
|
||||
- TTL: 24 hours
|
||||
|
||||
## ✅ Production Checklist
|
||||
|
||||
- [ ] Generate production VAPID keys
|
||||
- [ ] Add to production environment variables
|
||||
- [ ] Run `npx prisma db push` on production DB
|
||||
- [ ] Verify HTTPS is working
|
||||
- [ ] Test on actual iOS device
|
||||
- [ ] Monitor logs for errors
|
||||
|
||||
## 🎉 You're Done!
|
||||
|
||||
Your app now has:
|
||||
- ✅ Self-hosted push notifications
|
||||
- ✅ No external dependencies
|
||||
- ✅ iOS and Android support
|
||||
- ✅ Multi-device support
|
||||
- ✅ Automatic subscription management
|
||||
|
||||
Users will receive notifications when:
|
||||
- Items are added/removed/completed in shared lists
|
||||
- They're invited to lists
|
||||
- Their invitations are accepted
|
||||
- They're removed from lists
|
||||
|
||||
All without any external services or additional infrastructure!
|
||||
97
PUSH_NOTIFICATIONS_GUIDE.md
Normal file
97
PUSH_NOTIFICATIONS_GUIDE.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Web Push Notifications Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to implement Web Push notifications in your PWA using VAPID (self-hosted, no external service needed).
|
||||
|
||||
## Architecture
|
||||
|
||||
1. **VAPID Keys**: Generate once, store securely (like JWT secrets)
|
||||
2. **Client**: Request permission, subscribe to push notifications
|
||||
3. **Database**: Store push subscriptions per user
|
||||
4. **Server**: Send notifications when list events occur
|
||||
5. **Service Worker**: Receive and display notifications
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Generate VAPID Keys
|
||||
|
||||
Run this once to generate your VAPID keys:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
Add to `.env`:
|
||||
```
|
||||
VAPID_PUBLIC_KEY=your_public_key_here
|
||||
VAPID_PRIVATE_KEY=your_private_key_here
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install web-push
|
||||
```
|
||||
|
||||
### 3. Database Schema
|
||||
|
||||
Add `PushSubscription` model to store user subscriptions (see prisma/schema.prisma changes).
|
||||
|
||||
### 4. Client-Side Flow
|
||||
|
||||
1. User grants notification permission
|
||||
2. Service worker subscribes to push manager
|
||||
3. Subscription sent to server and stored in database
|
||||
4. Server sends notifications when events occur
|
||||
5. Service worker displays notifications
|
||||
|
||||
### 5. Notification Triggers
|
||||
|
||||
Notifications are sent when:
|
||||
- Someone adds/removes an item from a shared list
|
||||
- Someone invites you to a list
|
||||
- Someone accepts your list invitation
|
||||
- Someone removes you from a list
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Testing
|
||||
- Use Chrome DevTools > Application > Service Workers
|
||||
- Use Chrome DevTools > Application > Push Messaging to simulate
|
||||
|
||||
### iOS Testing
|
||||
- Must be installed to home screen
|
||||
- iOS 16.4+ required
|
||||
- Test on actual device (simulator doesn't support push)
|
||||
|
||||
### Android Testing
|
||||
- Works in browser and installed PWA
|
||||
- Test on actual device for best results
|
||||
|
||||
## Privacy & Best Practices
|
||||
|
||||
- Always request permission explicitly (don't auto-prompt)
|
||||
- Allow users to disable notifications
|
||||
- Store subscriptions securely
|
||||
- Handle subscription expiration/updates
|
||||
- Respect user preferences per list
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Notifications not appearing
|
||||
- Check permission status
|
||||
- Verify service worker is active
|
||||
- Check browser console for errors
|
||||
- Ensure VAPID keys are correct
|
||||
|
||||
### iOS specific issues
|
||||
- Must be installed to home screen
|
||||
- Check iOS version (16.4+)
|
||||
- Verify manifest.json is correct
|
||||
|
||||
### Subscription fails
|
||||
- Check VAPID public key matches server
|
||||
- Verify service worker scope
|
||||
- Check network requests in DevTools
|
||||
183
PUSH_NOTIFICATIONS_IMPLEMENTED.md
Normal file
183
PUSH_NOTIFICATIONS_IMPLEMENTED.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# ✅ Push Notifications - FULLY IMPLEMENTED
|
||||
|
||||
## What's Been Done
|
||||
|
||||
Your PWA now has a complete, self-hosted push notification system! Here's what was implemented:
|
||||
|
||||
### 1. Core Infrastructure ✅
|
||||
- **Database**: Added `PushSubscription` model to Prisma schema
|
||||
- **Server Logic**: Created `lib/push-notifications.ts` with VAPID support
|
||||
- **Notification Helpers**: Created `lib/notification-helpers.ts` for event-specific notifications
|
||||
- **API Routes**: 3 routes for subscription management
|
||||
- **Service Worker**: Updated with push event handlers
|
||||
- **React Hook**: Created `usePushNotifications` for client-side management
|
||||
- **UI Component**: Created `NotificationSettings` component
|
||||
|
||||
### 2. Integration Complete ✅
|
||||
- **Profile Page**: Added NotificationSettings component
|
||||
- **Item Actions**: Integrated notifications for:
|
||||
- ✅ Item added → `notifyItemAdded()`
|
||||
- ✅ Item removed → `notifyItemRemoved()`
|
||||
- ✅ Item completed → `notifyItemCompleted()`
|
||||
- **Sharing Actions**: Integrated notifications for:
|
||||
- ✅ List invitation → `notifyListInvitation()`
|
||||
- ✅ Invitation accepted → `notifyInvitationAccepted()`
|
||||
- ✅ Collaborator removed → `notifyRemovedFromList()`
|
||||
|
||||
### 3. Dependencies ✅
|
||||
- Added `web-push` package
|
||||
- Added `@types/web-push` for TypeScript
|
||||
- Added npm script: `npm run vapid:generate`
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 2: Generate VAPID Keys
|
||||
```bash
|
||||
npm run vapid:generate
|
||||
```
|
||||
|
||||
Copy the output to your `.env` file:
|
||||
```env
|
||||
VAPID_PUBLIC_KEY=BNxxx...
|
||||
VAPID_PRIVATE_KEY=xxx...
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
```
|
||||
|
||||
### Step 3: Update Database
|
||||
```bash
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
### Step 4: Restart Your Server
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## How to Use
|
||||
|
||||
### For Users:
|
||||
1. Go to Profile page
|
||||
2. Click "Enable" in the Notification Settings section
|
||||
3. Grant permission when browser prompts
|
||||
4. Done! You'll now receive notifications
|
||||
|
||||
### Notifications Are Sent When:
|
||||
- Someone adds an item to a shared list
|
||||
- Someone removes an item from a shared list
|
||||
- Someone completes an item in a shared list
|
||||
- Someone invites you to a list
|
||||
- Someone accepts your list invitation
|
||||
- You're removed from a list
|
||||
|
||||
## Technical Details
|
||||
|
||||
### The "Server" Solution
|
||||
You asked about the push notification server - **it's your own Next.js app!**
|
||||
|
||||
- Uses VAPID (Voluntary Application Server Identification)
|
||||
- No external services needed (no Firebase, no third-party APIs)
|
||||
- The `web-push` npm package handles the protocol
|
||||
- Your VAPID keys authenticate your server to browsers
|
||||
- Completely self-hosted and free
|
||||
|
||||
### How It Works
|
||||
1. User enables notifications → Browser creates subscription
|
||||
2. Subscription saved to your database
|
||||
3. Event occurs (item added, etc.) → Your server action runs
|
||||
4. Server sends push via `web-push` library
|
||||
5. Browser's push service delivers to device
|
||||
6. Service worker displays notification
|
||||
|
||||
### Browser Support
|
||||
- ✅ Chrome/Edge (Desktop & Android)
|
||||
- ✅ Firefox (Desktop & Android)
|
||||
- ✅ Safari 16.4+ (macOS & iOS when installed to home screen)
|
||||
|
||||
## Files Created
|
||||
|
||||
### Core Files
|
||||
- `lib/push-notifications.ts` - Server push logic
|
||||
- `lib/notification-helpers.ts` - Event-specific notifications
|
||||
- `hooks/usePushNotifications.ts` - React hook
|
||||
- `components/NotificationSettings.tsx` - UI component
|
||||
- `app/api/push/vapid-key/route.ts` - API route
|
||||
- `app/api/push/subscribe/route.ts` - API route
|
||||
- `app/api/push/unsubscribe/route.ts` - API route
|
||||
- `scripts/generate-vapid-keys.js` - Key generation script
|
||||
|
||||
### Documentation
|
||||
- `PUSH_NOTIFICATIONS_QUICK_START.md` - 5-minute setup
|
||||
- `PUSH_NOTIFICATIONS_COMPLETE.md` - Full overview
|
||||
- `PUSH_NOTIFICATIONS_SETUP.md` - Detailed setup
|
||||
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` - Code examples
|
||||
- `PUSH_NOTIFICATIONS_GUIDE.md` - Architecture guide
|
||||
- `PUSH_NOTIFICATIONS_IMPLEMENTED.md` - This file
|
||||
|
||||
### Modified Files
|
||||
- `prisma/schema.prisma` - Added PushSubscription model
|
||||
- `public/sw.js` - Added push event handlers
|
||||
- `package.json` - Added dependencies and script
|
||||
- `.env.example` - Added VAPID key placeholders
|
||||
- `app/profile/page.tsx` - Added NotificationSettings component
|
||||
- `lib/actions.ts` - Integrated notification calls
|
||||
- `lib/sharing-actions.ts` - Integrated notification calls
|
||||
|
||||
## Testing
|
||||
|
||||
### Desktop (Chrome)
|
||||
1. Start dev server: `npm run dev`
|
||||
2. Go to profile, enable notifications
|
||||
3. Open another browser/incognito window
|
||||
4. Log in as different user
|
||||
5. Add item to shared list
|
||||
6. See notification in first browser
|
||||
|
||||
### iOS (Real Device)
|
||||
1. Deploy with HTTPS
|
||||
2. Open in Safari
|
||||
3. Share → Add to Home Screen
|
||||
4. Open installed app
|
||||
5. Enable notifications in profile
|
||||
6. Test from another device
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Run `npm install`
|
||||
- [ ] Run `npm run vapid:generate`
|
||||
- [ ] Add VAPID keys to production `.env`
|
||||
- [ ] Run `npx prisma db push` on production database
|
||||
- [ ] Verify HTTPS is working
|
||||
- [ ] Test notifications on actual devices
|
||||
- [ ] Monitor logs for errors
|
||||
|
||||
## What Makes This Special
|
||||
|
||||
1. **Self-Hosted**: No external dependencies or costs
|
||||
2. **Privacy-Focused**: All data stays in your database
|
||||
3. **Multi-Device**: Users can subscribe on multiple devices
|
||||
4. **Automatic Cleanup**: Expired subscriptions removed automatically
|
||||
5. **Non-Blocking**: Notifications sent asynchronously
|
||||
6. **Graceful Degradation**: If notifications fail, actions still succeed
|
||||
|
||||
## Next Steps (Optional Enhancements)
|
||||
|
||||
- Add per-list notification preferences
|
||||
- Add quiet hours (don't notify during certain times)
|
||||
- Add notification history/inbox
|
||||
- Add action buttons to notifications
|
||||
- Add notification grouping/batching
|
||||
- Add sound customization
|
||||
|
||||
## Support
|
||||
|
||||
All files compile without errors. The implementation is complete and ready to use!
|
||||
|
||||
For questions, refer to:
|
||||
- `PUSH_NOTIFICATIONS_QUICK_START.md` for setup
|
||||
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` for code examples
|
||||
- `PUSH_NOTIFICATIONS_SETUP.md` for troubleshooting
|
||||
338
PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
Normal file
338
PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Push Notifications Integration Example
|
||||
|
||||
This shows exactly how to add notifications to your existing code.
|
||||
|
||||
## Step 1: Import the notification helpers
|
||||
|
||||
At the top of your action files, add:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
notifyItemAdded,
|
||||
notifyItemRemoved,
|
||||
notifyItemCompleted,
|
||||
notifyListInvitation,
|
||||
notifyInvitationAccepted,
|
||||
notifyRemovedFromList,
|
||||
} from '@/lib/notification-helpers';
|
||||
```
|
||||
|
||||
## Step 2: Add notification calls after actions
|
||||
|
||||
### Example: Adding an item
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function createItem(listId: string, data: any) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.create({
|
||||
data: {
|
||||
...data,
|
||||
listId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
return item;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function createItem(listId: string, data: any) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.create({
|
||||
data: {
|
||||
...data,
|
||||
listId,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Add this line to send notifications
|
||||
await notifyItemAdded(listId, item.title, session.user.id);
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
return item;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Deleting an item
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function deleteItem(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
await prisma.item.delete({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
revalidatePath('/');
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function deleteItem(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
// 🔔 Get item details before deleting
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
await prisma.item.delete({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
// 🔔 Send notification
|
||||
if (item) {
|
||||
await notifyItemRemoved(item.listId, item.title, session.user.id);
|
||||
}
|
||||
|
||||
revalidatePath('/');
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Toggling item completion
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function toggleItemComplete(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
const updated = await prisma.item.update({
|
||||
where: { id: itemId },
|
||||
data: { completed: !item?.completed },
|
||||
});
|
||||
|
||||
revalidatePath('/');
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function toggleItemComplete(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
const updated = await prisma.item.update({
|
||||
where: { id: itemId },
|
||||
data: { completed: !item?.completed },
|
||||
});
|
||||
|
||||
// 🔔 Notify when item is completed (not uncompleted)
|
||||
if (updated.completed) {
|
||||
await notifyItemCompleted(updated.listId, updated.title, session.user.id);
|
||||
}
|
||||
|
||||
revalidatePath('/');
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Inviting to a list
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function inviteToList(listId: string, email: string, permission: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.create({
|
||||
data: {
|
||||
listId,
|
||||
email,
|
||||
permission,
|
||||
inviterId: session.user.id,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function inviteToList(listId: string, email: string, permission: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.create({
|
||||
data: {
|
||||
listId,
|
||||
email,
|
||||
permission,
|
||||
inviterId: session.user.id,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Notify the invitee
|
||||
await notifyListInvitation(invitation.id, email);
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Accepting an invitation
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function acceptInvitation(invitationId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: 'accepted' },
|
||||
});
|
||||
|
||||
await prisma.collaborator.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
listId: invitation.listId,
|
||||
permission: invitation.permission,
|
||||
},
|
||||
});
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function acceptInvitation(invitationId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: 'accepted' },
|
||||
});
|
||||
|
||||
await prisma.collaborator.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
listId: invitation.listId,
|
||||
permission: invitation.permission,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Notify the person who sent the invitation
|
||||
await notifyInvitationAccepted(invitationId);
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Removing a collaborator
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function removeCollaborator(listId: string, userId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
await prisma.collaborator.deleteMany({
|
||||
where: {
|
||||
listId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function removeCollaborator(listId: string, userId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
// 🔔 Get list details before removing
|
||||
const list = await prisma.list.findUnique({
|
||||
where: { id: listId },
|
||||
});
|
||||
|
||||
await prisma.collaborator.deleteMany({
|
||||
where: {
|
||||
listId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Notify the removed user
|
||||
if (list) {
|
||||
await notifyRemovedFromList(userId, list.title, session.user.id);
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Add NotificationSettings to Profile Page
|
||||
|
||||
In `app/profile/page.tsx`, add the component:
|
||||
|
||||
```typescript
|
||||
import NotificationSettings from '@/components/NotificationSettings';
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Profile Settings</h1>
|
||||
|
||||
{/* Your existing profile components */}
|
||||
<ProfileForm />
|
||||
<PasswordChangeForm />
|
||||
|
||||
{/* 🔔 Add notification settings */}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## That's It!
|
||||
|
||||
The notification system will:
|
||||
- ✅ Automatically handle permission requests
|
||||
- ✅ Store subscriptions per user/device
|
||||
- ✅ Send notifications to all relevant users
|
||||
- ✅ Exclude the user who triggered the action
|
||||
- ✅ Clean up expired subscriptions
|
||||
- ✅ Work across multiple devices
|
||||
|
||||
## Optional: Error Handling
|
||||
|
||||
If you want to handle notification failures gracefully:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await notifyItemAdded(listId, item.title, session.user.id);
|
||||
} catch (error) {
|
||||
// Log but don't fail the action
|
||||
console.error('Failed to send notification:', error);
|
||||
}
|
||||
```
|
||||
|
||||
Notifications are non-critical, so it's fine to let them fail silently without affecting the main action.
|
||||
82
PUSH_NOTIFICATIONS_QUICK_START.md
Normal file
82
PUSH_NOTIFICATIONS_QUICK_START.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Push Notifications - Quick Start
|
||||
|
||||
## 🚀 5-Minute Setup
|
||||
|
||||
### 1. Install & Generate Keys
|
||||
```bash
|
||||
npm install
|
||||
npm run vapid:generate
|
||||
```
|
||||
|
||||
### 2. Add Keys to .env
|
||||
```env
|
||||
VAPID_PUBLIC_KEY=<copy from output>
|
||||
VAPID_PRIVATE_KEY=<copy from output>
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
```
|
||||
|
||||
### 3. Update Database
|
||||
```bash
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
### 4. Add to Profile Page
|
||||
```typescript
|
||||
// app/profile/page.tsx
|
||||
import NotificationSettings from '@/components/NotificationSettings';
|
||||
|
||||
// Add <NotificationSettings /> to your page
|
||||
```
|
||||
|
||||
### 5. Send Notifications
|
||||
```typescript
|
||||
// In your action files
|
||||
import { notifyItemAdded } from '@/lib/notification-helpers';
|
||||
|
||||
// After creating an item:
|
||||
await notifyItemAdded(listId, item.title, session.user.id);
|
||||
```
|
||||
|
||||
## ✅ Done!
|
||||
|
||||
Users can now:
|
||||
- Enable notifications in their profile
|
||||
- Receive push notifications on all their devices
|
||||
- Get notified about list activity even when the app is closed
|
||||
|
||||
## 📱 iOS Support
|
||||
|
||||
For iOS notifications:
|
||||
1. Deploy with HTTPS
|
||||
2. User must "Add to Home Screen"
|
||||
3. Requires iOS 16.4+
|
||||
|
||||
## 🔧 Available Notification Functions
|
||||
|
||||
```typescript
|
||||
// Item notifications
|
||||
notifyItemAdded(listId, itemTitle, userId)
|
||||
notifyItemRemoved(listId, itemTitle, userId)
|
||||
notifyItemCompleted(listId, itemTitle, userId)
|
||||
|
||||
// Invitation notifications
|
||||
notifyListInvitation(invitationId, email)
|
||||
notifyInvitationAccepted(invitationId)
|
||||
|
||||
// Collaboration notifications
|
||||
notifyRemovedFromList(userId, listTitle, removedByUserId)
|
||||
```
|
||||
|
||||
## 📚 Full Documentation
|
||||
|
||||
- **PUSH_NOTIFICATIONS_SETUP.md** - Complete setup guide
|
||||
- **PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md** - Code examples
|
||||
- **PUSH_NOTIFICATIONS_GUIDE.md** - Architecture & testing
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
- ✅ Self-hosted (no external services)
|
||||
- ✅ Works on iOS, Android, Desktop
|
||||
- ✅ Multiple devices per user
|
||||
- ✅ Automatic subscription cleanup
|
||||
- ✅ Non-blocking (won't slow down your app)
|
||||
271
PUSH_NOTIFICATIONS_SETUP.md
Normal file
271
PUSH_NOTIFICATIONS_SETUP.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Push Notifications Setup Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This will install `web-push` which is now in package.json.
|
||||
|
||||
### 2. Generate VAPID Keys
|
||||
|
||||
```bash
|
||||
npm run vapid:generate
|
||||
```
|
||||
|
||||
Copy the output and add to your `.env` file:
|
||||
|
||||
```env
|
||||
VAPID_PUBLIC_KEY=your-generated-public-key
|
||||
VAPID_PRIVATE_KEY=your-generated-private-key
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
```
|
||||
|
||||
### 3. Update Database Schema
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
This adds the `PushSubscription` table to your database.
|
||||
|
||||
### 4. Add Notification Settings to Profile Page
|
||||
|
||||
Add the `NotificationSettings` component to your profile page:
|
||||
|
||||
```tsx
|
||||
import NotificationSettings from '@/components/NotificationSettings';
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Your existing profile content */}
|
||||
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Integrate Notifications into Your Actions
|
||||
|
||||
Update your existing action files to send notifications:
|
||||
|
||||
#### In `lib/actions.ts` (or wherever you handle items):
|
||||
|
||||
```typescript
|
||||
import { notifyItemAdded, notifyItemRemoved, notifyItemCompleted } from '@/lib/notification-helpers';
|
||||
|
||||
// After creating an item:
|
||||
export async function createItem(listId: string, data: ItemData) {
|
||||
// ... your existing code to create item
|
||||
const item = await prisma.item.create({ ... });
|
||||
|
||||
// Send notification
|
||||
await notifyItemAdded(listId, item.title, session.user.id);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// After deleting an item:
|
||||
export async function deleteItem(itemId: string) {
|
||||
const item = await prisma.item.findUnique({ where: { id: itemId } });
|
||||
// ... your existing delete code
|
||||
|
||||
if (item) {
|
||||
await notifyItemRemoved(item.listId, item.title, session.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// After completing an item:
|
||||
export async function toggleItemComplete(itemId: string) {
|
||||
const item = await prisma.item.update({ ... });
|
||||
|
||||
if (item.completed) {
|
||||
await notifyItemCompleted(item.listId, item.title, session.user.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### In `lib/sharing-actions.ts`:
|
||||
|
||||
```typescript
|
||||
import { notifyListInvitation, notifyInvitationAccepted, notifyRemovedFromList } from '@/lib/notification-helpers';
|
||||
|
||||
// After creating an invitation:
|
||||
export async function inviteToList(listId: string, email: string, permission: string) {
|
||||
// ... your existing code
|
||||
const invitation = await prisma.invitation.create({ ... });
|
||||
|
||||
// Send notification
|
||||
await notifyListInvitation(invitation.id, email);
|
||||
|
||||
return invitation;
|
||||
}
|
||||
|
||||
// After accepting an invitation:
|
||||
export async function acceptInvitation(invitationId: string) {
|
||||
// ... your existing code
|
||||
|
||||
await notifyInvitationAccepted(invitationId);
|
||||
}
|
||||
|
||||
// After removing a collaborator:
|
||||
export async function removeCollaborator(listId: string, userId: string) {
|
||||
const list = await prisma.list.findUnique({ where: { id: listId } });
|
||||
// ... your existing code
|
||||
|
||||
if (list) {
|
||||
await notifyRemovedFromList(userId, list.title, session.user.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Architecture
|
||||
|
||||
1. **Client requests permission** → User clicks "Enable" in NotificationSettings
|
||||
2. **Service worker subscribes** → Browser creates a push subscription
|
||||
3. **Subscription saved to DB** → Stored in PushSubscription table
|
||||
4. **Events trigger notifications** → Server sends push via web-push library
|
||||
5. **Service worker displays** → User sees notification
|
||||
|
||||
### Self-Hosted Solution
|
||||
|
||||
This implementation uses **VAPID** (Voluntary Application Server Identification), which means:
|
||||
- ✅ No external services required (no Firebase, no third-party APIs)
|
||||
- ✅ Works entirely from your own server
|
||||
- ✅ Free and open-source
|
||||
- ✅ Full control over your data
|
||||
|
||||
### Browser Support
|
||||
|
||||
- ✅ Chrome/Edge (Desktop & Android)
|
||||
- ✅ Firefox (Desktop & Android)
|
||||
- ✅ Safari 16.4+ (iOS & macOS) - **Must be installed to home screen on iOS**
|
||||
- ❌ Safari < 16.4
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Testing (Desktop)
|
||||
|
||||
1. Start your dev server: `npm run dev`
|
||||
2. Open Chrome DevTools → Application → Service Workers
|
||||
3. Go to your profile page and enable notifications
|
||||
4. Trigger an action (add item, invite someone, etc.)
|
||||
5. You should see a notification appear
|
||||
|
||||
### iOS Testing (Real Device Required)
|
||||
|
||||
1. Deploy to a server with HTTPS (required for PWA)
|
||||
2. Open Safari on iOS 16.4+
|
||||
3. Tap Share → Add to Home Screen
|
||||
4. Open the installed app
|
||||
5. Enable notifications in profile
|
||||
6. Test by triggering actions from another device
|
||||
|
||||
### Testing Push Manually
|
||||
|
||||
Use Chrome DevTools to simulate a push:
|
||||
|
||||
1. DevTools → Application → Service Workers
|
||||
2. Find your service worker
|
||||
3. Click "Push" to simulate receiving a notification
|
||||
|
||||
## Notification Events
|
||||
|
||||
The following events trigger notifications:
|
||||
|
||||
| Event | Recipient | Trigger |
|
||||
|-------|-----------|---------|
|
||||
| Item Added | All list collaborators (except adder) | Someone adds an item to a shared list |
|
||||
| Item Removed | All list collaborators (except remover) | Someone removes an item |
|
||||
| Item Completed | All list collaborators (except completer) | Someone marks an item complete |
|
||||
| List Invitation | Invitee | Someone invites you to a list |
|
||||
| Invitation Accepted | Inviter | Someone accepts your invitation |
|
||||
| Removed from List | Removed user | Owner removes you from a list |
|
||||
|
||||
## Customization
|
||||
|
||||
### Notification Content
|
||||
|
||||
Edit `lib/notification-helpers.ts` to customize notification titles, bodies, and icons.
|
||||
|
||||
### Notification Preferences
|
||||
|
||||
To add per-list notification preferences:
|
||||
|
||||
1. Add a `notificationsEnabled` field to the `Collaborator` model
|
||||
2. Update `NotificationSettings` to show per-list toggles
|
||||
3. Check this field in `notifyListCollaborators` before sending
|
||||
|
||||
### Notification Sounds
|
||||
|
||||
Add to service worker push event:
|
||||
|
||||
```javascript
|
||||
const options = {
|
||||
// ... existing options
|
||||
silent: false,
|
||||
sound: '/notification-sound.mp3',
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Push notifications not configured" error
|
||||
- Check that VAPID keys are in `.env`
|
||||
- Restart your dev server after adding keys
|
||||
|
||||
### Notifications not appearing
|
||||
- Check browser permission (should be "granted")
|
||||
- Verify service worker is active (DevTools → Application)
|
||||
- Check browser console for errors
|
||||
- Ensure you're testing on HTTPS (or localhost)
|
||||
|
||||
### iOS notifications not working
|
||||
- Must be iOS 16.4 or later
|
||||
- Must be installed to home screen (not in Safari browser)
|
||||
- Check Settings → [Your App] → Notifications
|
||||
|
||||
### Subscription fails
|
||||
- Verify VAPID public key matches between client and server
|
||||
- Check service worker is registered and active
|
||||
- Ensure you're on HTTPS (or localhost)
|
||||
|
||||
## Security Notes
|
||||
|
||||
- ⚠️ **Never commit VAPID_PRIVATE_KEY to git**
|
||||
- ✅ VAPID keys are like JWT secrets - keep them secure
|
||||
- ✅ Subscriptions are tied to users - no cross-user notification access
|
||||
- ✅ Expired subscriptions are automatically cleaned up
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Generate production VAPID keys
|
||||
- [ ] Add VAPID keys to production environment variables
|
||||
- [ ] Run database migration: `npx prisma db push`
|
||||
- [ ] Test on actual iOS device (if targeting iOS)
|
||||
- [ ] Verify HTTPS is working (required for push)
|
||||
- [ ] Test notification delivery
|
||||
- [ ] Monitor for expired subscriptions in logs
|
||||
|
||||
## Performance
|
||||
|
||||
- Notifications are sent asynchronously (non-blocking)
|
||||
- Failed subscriptions are automatically removed
|
||||
- Multiple devices per user are supported
|
||||
- TTL is set to 24 hours (notifications expire if not delivered)
|
||||
|
||||
## Next Steps
|
||||
|
||||
Consider adding:
|
||||
- Notification preferences per list
|
||||
- Quiet hours (don't notify during certain times)
|
||||
- Notification history/inbox
|
||||
- Rich notifications with action buttons
|
||||
- Notification grouping/batching
|
||||
33
app/api/push/subscribe/route.ts
Normal file
33
app/api/push/subscribe/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]';
|
||||
import { subscribeToPush } from '@/lib/push-notifications';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const subscription = await request.json();
|
||||
|
||||
if (!subscription.endpoint || !subscription.keys) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid subscription object' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await subscribeToPush(session.user.id, subscription);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe to push:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to subscribe to push notifications' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
app/api/push/unsubscribe/route.ts
Normal file
33
app/api/push/unsubscribe/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]';
|
||||
import { unsubscribeFromPush } from '@/lib/push-notifications';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { endpoint } = await request.json();
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endpoint required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await unsubscribeFromPush(session.user.id, endpoint);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to unsubscribe from push:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to unsubscribe from push notifications' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
app/api/push/vapid-key/route.ts
Normal file
23
app/api/push/vapid-key/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getVapidPublicKey } from '@/lib/push-notifications';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const publicKey = await getVapidPublicKey();
|
||||
|
||||
if (!publicKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Push notifications not configured' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ publicKey });
|
||||
} catch (error) {
|
||||
console.error('Failed to get VAPID public key:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get VAPID public key' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { getUserProfile } from '@/lib/auth-actions'
|
||||
import ProfileForm from '@/components/ProfileForm'
|
||||
import PasswordChangeForm from '@/components/PasswordChangeForm'
|
||||
import NotificationSettings from '@/components/NotificationSettings'
|
||||
import Link from 'next/link'
|
||||
|
||||
export const metadata = {
|
||||
@@ -82,6 +83,11 @@ export default async function ProfilePage() {
|
||||
<PasswordChangeForm userId={userProfile.id} />
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="mb-6">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
|
||||
{/* Account Information */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-4">
|
||||
|
||||
76
components/NotificationSettings.tsx
Normal file
76
components/NotificationSettings.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||
import { Bell, BellOff } from 'lucide-react';
|
||||
|
||||
export default function NotificationSettings() {
|
||||
const { permission, isSubscribed, isLoading, error, subscribe, unsubscribe } =
|
||||
usePushNotifications();
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (isSubscribed) {
|
||||
await unsubscribe();
|
||||
} else {
|
||||
await subscribe();
|
||||
}
|
||||
};
|
||||
|
||||
// Don't show if notifications aren't supported
|
||||
if (!('Notification' in window) || !('serviceWorker' in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
{isSubscribed ? <Bell className="w-5 h-5" /> : <BellOff className="w-5 h-5" />}
|
||||
Push Notifications
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Get notified when someone adds items, shares lists, or invites you to collaborate
|
||||
</p>
|
||||
|
||||
{permission === 'denied' && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-800">
|
||||
Notifications are blocked. Please enable them in your browser settings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSubscribed && (
|
||||
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ You'll receive notifications on this device
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || permission === 'denied'}
|
||||
className={`ml-4 px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
isSubscribed
|
||||
? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{isLoading ? 'Loading...' : isSubscribed ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-500">
|
||||
<p>💡 Tip: Install this app to your home screen for the best notification experience</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
hooks/usePushNotifications.ts
Normal file
170
hooks/usePushNotifications.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function usePushNotifications() {
|
||||
const [permission, setPermission] = useState<NotificationPermission>('default');
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window) {
|
||||
setPermission(Notification.permission);
|
||||
checkSubscription();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkSubscription = async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setIsSubscribed(!!subscription);
|
||||
} catch (err) {
|
||||
console.error('Failed to check subscription:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const requestPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
setError('Notifications not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
return result === 'granted';
|
||||
} catch (err) {
|
||||
console.error('Failed to request permission:', err);
|
||||
setError('Failed to request permission');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
setError('Push notifications not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Request permission if not granted
|
||||
if (permission !== 'granted') {
|
||||
const granted = await requestPermission();
|
||||
if (!granted) {
|
||||
setError('Permission denied');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get service worker registration
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// Get VAPID public key from server
|
||||
const response = await fetch('/api/push/vapid-key');
|
||||
const { publicKey } = await response.json();
|
||||
|
||||
if (!publicKey) {
|
||||
throw new Error('VAPID public key not available');
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
|
||||
// Send subscription to server
|
||||
const saveResponse = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
throw new Error('Failed to save subscription');
|
||||
}
|
||||
|
||||
setIsSubscribed(true);
|
||||
setIsLoading(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to subscribe:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to subscribe');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
// Unsubscribe from push manager
|
||||
await subscription.unsubscribe();
|
||||
|
||||
// Remove subscription from server
|
||||
await fetch('/api/push/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubscribed(false);
|
||||
setIsLoading(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to unsubscribe:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
permission,
|
||||
isSubscribed,
|
||||
isLoading,
|
||||
error,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestPermission,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to convert VAPID key
|
||||
function 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;
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
validateTags,
|
||||
sanitizeString,
|
||||
} from './validation'
|
||||
import {
|
||||
notifyItemAdded,
|
||||
notifyItemRemoved,
|
||||
notifyItemCompleted,
|
||||
} from './notification-helpers'
|
||||
|
||||
// Retry utility for database operations
|
||||
async function retryOperation<T>(
|
||||
@@ -370,6 +375,14 @@ export async function createItem(
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification to collaborators
|
||||
try {
|
||||
await notifyItemAdded(listId, item.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`)
|
||||
return item
|
||||
} catch (error) {
|
||||
@@ -437,6 +450,15 @@ export async function toggleItem(itemId: string) {
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification when item is completed (not uncompleted)
|
||||
if (updated.completed) {
|
||||
try {
|
||||
await notifyItemCompleted(item.listId, updated.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${item.listId}`)
|
||||
return updated
|
||||
} catch (error) {
|
||||
@@ -609,6 +631,13 @@ export async function deleteItem(itemId: string) {
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification to collaborators
|
||||
try {
|
||||
await notifyItemRemoved(item.listId, item.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error)
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${item.listId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
201
lib/notification-helpers.ts
Normal file
201
lib/notification-helpers.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
'use server';
|
||||
|
||||
import { sendPushNotification, notifyListCollaborators } from '@/lib/push-notifications';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* Notify when an item is added to a shared list
|
||||
*/
|
||||
export async function notifyItemAdded(
|
||||
listId: string,
|
||||
itemTitle: string,
|
||||
addedByUserId: string
|
||||
) {
|
||||
const list = await prisma.list.findUnique({
|
||||
where: { id: listId },
|
||||
include: { owner: true },
|
||||
});
|
||||
|
||||
if (!list) return;
|
||||
|
||||
const addedByUser = await prisma.user.findUnique({
|
||||
where: { id: addedByUserId },
|
||||
});
|
||||
|
||||
await notifyListCollaborators(
|
||||
listId,
|
||||
{
|
||||
title: `New item in "${list.title}"`,
|
||||
body: `${addedByUser?.name || 'Someone'} added "${itemTitle}"`,
|
||||
icon: '/icon-192.svg',
|
||||
tag: `item-added-${listId}`,
|
||||
data: {
|
||||
url: `/list/${listId}`,
|
||||
listId,
|
||||
action: 'item-added',
|
||||
},
|
||||
},
|
||||
addedByUserId // Don't notify the person who added it
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify when an item is removed from a shared list
|
||||
*/
|
||||
export async function notifyItemRemoved(
|
||||
listId: string,
|
||||
itemTitle: string,
|
||||
removedByUserId: string
|
||||
) {
|
||||
const list = await prisma.list.findUnique({
|
||||
where: { id: listId },
|
||||
include: { owner: true },
|
||||
});
|
||||
|
||||
if (!list) return;
|
||||
|
||||
const removedByUser = await prisma.user.findUnique({
|
||||
where: { id: removedByUserId },
|
||||
});
|
||||
|
||||
await notifyListCollaborators(
|
||||
listId,
|
||||
{
|
||||
title: `Item removed from "${list.title}"`,
|
||||
body: `${removedByUser?.name || 'Someone'} removed "${itemTitle}"`,
|
||||
icon: '/icon-192.svg',
|
||||
tag: `item-removed-${listId}`,
|
||||
data: {
|
||||
url: `/list/${listId}`,
|
||||
listId,
|
||||
action: 'item-removed',
|
||||
},
|
||||
},
|
||||
removedByUserId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify when an item is completed
|
||||
*/
|
||||
export async function notifyItemCompleted(
|
||||
listId: string,
|
||||
itemTitle: string,
|
||||
completedByUserId: string
|
||||
) {
|
||||
const list = await prisma.list.findUnique({
|
||||
where: { id: listId },
|
||||
include: { owner: true },
|
||||
});
|
||||
|
||||
if (!list) return;
|
||||
|
||||
const completedByUser = await prisma.user.findUnique({
|
||||
where: { id: completedByUserId },
|
||||
});
|
||||
|
||||
await notifyListCollaborators(
|
||||
listId,
|
||||
{
|
||||
title: `Item completed in "${list.title}"`,
|
||||
body: `${completedByUser?.name || 'Someone'} completed "${itemTitle}"`,
|
||||
icon: '/icon-192.svg',
|
||||
tag: `item-completed-${listId}`,
|
||||
data: {
|
||||
url: `/list/${listId}`,
|
||||
listId,
|
||||
action: 'item-completed',
|
||||
},
|
||||
},
|
||||
completedByUserId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify when invited to a list
|
||||
*/
|
||||
export async function notifyListInvitation(
|
||||
invitationId: string,
|
||||
inviteeEmail: string
|
||||
) {
|
||||
const invitation = await prisma.invitation.findUnique({
|
||||
where: { id: invitationId },
|
||||
include: {
|
||||
list: true,
|
||||
inviter: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitation) return;
|
||||
|
||||
// Find the invitee user
|
||||
const invitee = await prisma.user.findUnique({
|
||||
where: { email: inviteeEmail },
|
||||
});
|
||||
|
||||
if (!invitee) return;
|
||||
|
||||
await sendPushNotification(invitee.id, {
|
||||
title: 'New list invitation',
|
||||
body: `${invitation.inviter.name || 'Someone'} invited you to "${invitation.list.title}"`,
|
||||
icon: '/icon-192.svg',
|
||||
tag: `invitation-${invitationId}`,
|
||||
data: {
|
||||
url: '/invitations',
|
||||
action: 'invitation-received',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify when someone accepts your invitation
|
||||
*/
|
||||
export async function notifyInvitationAccepted(
|
||||
invitationId: string
|
||||
) {
|
||||
const invitation = await prisma.invitation.findUnique({
|
||||
where: { id: invitationId },
|
||||
include: {
|
||||
list: true,
|
||||
receiver: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitation || !invitation.receiver) return;
|
||||
|
||||
await sendPushNotification(invitation.inviterId, {
|
||||
title: 'Invitation accepted',
|
||||
body: `${invitation.receiver.name || invitation.email} joined "${invitation.list.title}"`,
|
||||
icon: '/icon-192.svg',
|
||||
tag: `invitation-accepted-${invitationId}`,
|
||||
data: {
|
||||
url: `/list/${invitation.listId}`,
|
||||
listId: invitation.listId,
|
||||
action: 'invitation-accepted',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify when removed from a list
|
||||
*/
|
||||
export async function notifyRemovedFromList(
|
||||
userId: string,
|
||||
listTitle: string,
|
||||
removedByUserId: string
|
||||
) {
|
||||
const removedByUser = await prisma.user.findUnique({
|
||||
where: { id: removedByUserId },
|
||||
});
|
||||
|
||||
await sendPushNotification(userId, {
|
||||
title: 'Removed from list',
|
||||
body: `${removedByUser?.name || 'The owner'} removed you from "${listTitle}"`,
|
||||
icon: '/icon-192.svg',
|
||||
tag: `removed-from-list`,
|
||||
data: {
|
||||
url: '/',
|
||||
action: 'removed-from-list',
|
||||
},
|
||||
});
|
||||
}
|
||||
191
lib/push-notifications.ts
Normal file
191
lib/push-notifications.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
'use server';
|
||||
|
||||
import webpush from 'web-push';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
// Configure web-push with VAPID keys
|
||||
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT) {
|
||||
webpush.setVapidDetails(
|
||||
process.env.VAPID_SUBJECT,
|
||||
process.env.VAPID_PUBLIC_KEY,
|
||||
process.env.VAPID_PRIVATE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
export interface PushNotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
tag?: string;
|
||||
data?: {
|
||||
url?: string;
|
||||
listId?: string;
|
||||
itemId?: string;
|
||||
action?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a user to push notifications
|
||||
*/
|
||||
export async function subscribeToPush(userId: string, subscription: PushSubscriptionJSON) {
|
||||
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
|
||||
throw new Error('Invalid subscription object');
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: {
|
||||
userId_endpoint: {
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
update: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to save push subscription:', error);
|
||||
throw new Error('Failed to subscribe to push notifications');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a user from push notifications
|
||||
*/
|
||||
export async function unsubscribeFromPush(userId: string, endpoint: string) {
|
||||
try {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
endpoint,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to remove push subscription:', error);
|
||||
throw new Error('Failed to unsubscribe from push notifications');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a push notification to a specific user
|
||||
*/
|
||||
export async function sendPushNotification(userId: string, payload: PushNotificationPayload) {
|
||||
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) {
|
||||
console.warn('VAPID keys not configured, skipping push notification');
|
||||
return { success: false, error: 'Push notifications not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all push subscriptions for the user
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
// Send notification to all user's devices
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (sub) => {
|
||||
const pushSubscription = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
pushSubscription,
|
||||
JSON.stringify(payload),
|
||||
{
|
||||
TTL: 60 * 60 * 24, // 24 hours
|
||||
}
|
||||
);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
// Handle expired or invalid subscriptions
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
// Subscription expired, remove it
|
||||
await prisma.pushSubscription.delete({
|
||||
where: { id: sub.id },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
return { success: true, sent: successful, total: subscriptions.length };
|
||||
} catch (error) {
|
||||
console.error('Failed to send push notification:', error);
|
||||
return { success: false, error: 'Failed to send notification' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to all collaborators of a list
|
||||
*/
|
||||
export async function notifyListCollaborators(
|
||||
listId: string,
|
||||
payload: PushNotificationPayload,
|
||||
excludeUserId?: string
|
||||
) {
|
||||
try {
|
||||
// Get list owner and collaborators
|
||||
const list = await prisma.list.findUnique({
|
||||
where: { id: listId },
|
||||
include: {
|
||||
collaborators: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!list) {
|
||||
return { success: false, error: 'List not found' };
|
||||
}
|
||||
|
||||
// Collect all user IDs (owner + collaborators)
|
||||
const userIds = [
|
||||
list.ownerId,
|
||||
...list.collaborators.map((c) => c.userId),
|
||||
].filter((id) => id !== excludeUserId); // Exclude the user who triggered the action
|
||||
|
||||
// Send notifications to all users
|
||||
const results = await Promise.allSettled(
|
||||
userIds.map((userId) => sendPushNotification(userId, payload))
|
||||
);
|
||||
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
return { success: true, notified: successful, total: userIds.length };
|
||||
} catch (error) {
|
||||
console.error('Failed to notify list collaborators:', error);
|
||||
return { success: false, error: 'Failed to send notifications' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VAPID public key for client-side subscription
|
||||
*/
|
||||
export async function getVapidPublicKey() {
|
||||
return process.env.VAPID_PUBLIC_KEY || null;
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
|
||||
import {
|
||||
notifyListInvitation,
|
||||
notifyInvitationAccepted,
|
||||
notifyRemovedFromList,
|
||||
} from './notification-helpers'
|
||||
|
||||
// Validation schemas
|
||||
const inviteSchema = z.object({
|
||||
@@ -163,6 +168,14 @@ export async function inviteUserToList(
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification to the invitee
|
||||
try {
|
||||
await notifyListInvitation(invitation.id, validEmail)
|
||||
} catch (error) {
|
||||
console.error('Failed to send invitation notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`)
|
||||
return invitation
|
||||
} catch (error) {
|
||||
@@ -320,6 +333,14 @@ export async function acceptInvitation(invitationId: string) {
|
||||
})
|
||||
)
|
||||
|
||||
// Notify the person who sent the invitation
|
||||
try {
|
||||
await notifyInvitationAccepted(invitationId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send acceptance notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath(`/list/${invitation.listId}`)
|
||||
return result
|
||||
@@ -592,6 +613,20 @@ export async function removeCollaborator(
|
||||
throw new Error('List not found or you do not have permission to remove collaborators')
|
||||
}
|
||||
|
||||
// Get collaborator details before deleting
|
||||
const collaborator = await retryOperation(() =>
|
||||
prisma.collaborator.findUnique({
|
||||
where: { id: collaboratorId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// Delete the collaborator
|
||||
await retryOperation(() =>
|
||||
prisma.collaborator.delete({
|
||||
@@ -602,6 +637,16 @@ export async function removeCollaborator(
|
||||
})
|
||||
)
|
||||
|
||||
// Notify the removed user
|
||||
if (collaborator) {
|
||||
try {
|
||||
await notifyRemovedFromList(collaborator.user.id, list.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send removal notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"db:studio": "prisma studio",
|
||||
"db:migrate": "npx tsx scripts/migrate-lists.ts",
|
||||
"icons:generate": "node scripts/generate-icons.js",
|
||||
"icons:info": "node scripts/svg-to-png.js"
|
||||
"icons:info": "node scripts/svg-to-png.js",
|
||||
"vapid:generate": "node scripts/generate-vapid-keys.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
@@ -25,6 +26,7 @@
|
||||
"next-auth": "^4.24.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,6 +34,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/web-push": "^3.6.3",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
|
||||
@@ -8,20 +8,35 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ownedLists List[] @relation("ListOwner")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ownedLists List[] @relation("ListOwner")
|
||||
collaborations Collaborator[]
|
||||
sentInvitations Invitation[] @relation("InvitationInviter")
|
||||
receivedInvitations Invitation[] @relation("InvitationReceiver")
|
||||
sentInvitations Invitation[] @relation("InvitationInviter")
|
||||
receivedInvitations Invitation[] @relation("InvitationReceiver")
|
||||
pushSubscriptions PushSubscription[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
endpoint String @db.Text
|
||||
p256dh String @db.Text
|
||||
auth String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, endpoint(255)])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model List {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
|
||||
50
public/sw.js
50
public/sw.js
@@ -280,3 +280,53 @@ self.addEventListener('message', (event) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Push notification event - receive and display notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = event.data.json();
|
||||
const { title, body, icon, badge, tag, data: notificationData } = data;
|
||||
|
||||
const options = {
|
||||
body,
|
||||
icon: icon || '/icon-192.svg',
|
||||
badge: badge || '/icon-192.svg',
|
||||
tag: tag || 'default',
|
||||
data: notificationData,
|
||||
vibrate: [200, 100, 200],
|
||||
requireInteraction: false,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, options)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to show notification:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Notification click event - handle user interaction
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of clientList) {
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open a new window if none exists
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
20
scripts/generate-vapid-keys.js
Normal file
20
scripts/generate-vapid-keys.js
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate VAPID keys for Web Push notifications
|
||||
* Run: node scripts/generate-vapid-keys.js
|
||||
*/
|
||||
|
||||
const webpush = require('web-push');
|
||||
|
||||
console.log('Generating VAPID keys for Web Push notifications...\n');
|
||||
|
||||
const vapidKeys = webpush.generateVAPIDKeys();
|
||||
|
||||
console.log('✓ VAPID keys generated successfully!\n');
|
||||
console.log('Add these to your .env file:\n');
|
||||
console.log('# Web Push Notifications (VAPID)');
|
||||
console.log(`VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
|
||||
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
|
||||
console.log('VAPID_SUBJECT=mailto:your-email@example.com');
|
||||
console.log('\n⚠️ Keep the private key secret! Do not commit it to version control.\n');
|
||||
Reference in New Issue
Block a user