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:
2025-12-07 01:22:16 +00:00
parent 625ca40e48
commit 7cda58440f
21 changed files with 2144 additions and 8 deletions

View File

@@ -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

View 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!

View 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

View 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

View 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.

View 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
View 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

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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">

View 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>
);
}

View 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;
}

View File

@@ -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
View 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
View 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;
}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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

View File

@@ -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);
}
})
);
});

View 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');