diff --git a/.env.example b/.env.example
index 954c259..26cb140 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/PUSH_NOTIFICATIONS_COMPLETE.md b/PUSH_NOTIFICATIONS_COMPLETE.md
new file mode 100644
index 0000000..6652348
--- /dev/null
+++ b/PUSH_NOTIFICATIONS_COMPLETE.md
@@ -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 (
+
+ {/* Your existing content */}
+
+
+ );
+}
+```
+
+### 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!
diff --git a/PUSH_NOTIFICATIONS_GUIDE.md b/PUSH_NOTIFICATIONS_GUIDE.md
new file mode 100644
index 0000000..e5bd352
--- /dev/null
+++ b/PUSH_NOTIFICATIONS_GUIDE.md
@@ -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
diff --git a/PUSH_NOTIFICATIONS_IMPLEMENTED.md b/PUSH_NOTIFICATIONS_IMPLEMENTED.md
new file mode 100644
index 0000000..fdd6463
--- /dev/null
+++ b/PUSH_NOTIFICATIONS_IMPLEMENTED.md
@@ -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
diff --git a/PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md b/PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
new file mode 100644
index 0000000..6eb361c
--- /dev/null
+++ b/PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
@@ -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 (
+
+
Profile Settings
+
+ {/* Your existing profile components */}
+
+
+
+ {/* ๐ Add notification settings */}
+
+
+ );
+}
+```
+
+## 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.
diff --git a/PUSH_NOTIFICATIONS_QUICK_START.md b/PUSH_NOTIFICATIONS_QUICK_START.md
new file mode 100644
index 0000000..8f4199b
--- /dev/null
+++ b/PUSH_NOTIFICATIONS_QUICK_START.md
@@ -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=
+VAPID_PRIVATE_KEY=
+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 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)
diff --git a/PUSH_NOTIFICATIONS_SETUP.md b/PUSH_NOTIFICATIONS_SETUP.md
new file mode 100644
index 0000000..251ea42
--- /dev/null
+++ b/PUSH_NOTIFICATIONS_SETUP.md
@@ -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 (
+
+ {/* Your existing profile content */}
+
+
+
+ );
+}
+```
+
+### 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
diff --git a/app/api/push/subscribe/route.ts b/app/api/push/subscribe/route.ts
new file mode 100644
index 0000000..fd3339d
--- /dev/null
+++ b/app/api/push/subscribe/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/push/unsubscribe/route.ts b/app/api/push/unsubscribe/route.ts
new file mode 100644
index 0000000..3a2fc1c
--- /dev/null
+++ b/app/api/push/unsubscribe/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/push/vapid-key/route.ts b/app/api/push/vapid-key/route.ts
new file mode 100644
index 0000000..bd0e23c
--- /dev/null
+++ b/app/api/push/vapid-key/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/profile/page.tsx b/app/profile/page.tsx
index 46c9435..410ba07 100644
--- a/app/profile/page.tsx
+++ b/app/profile/page.tsx
@@ -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() {
+ {/* Notification Settings */}
+
+
+
+
{/* Account Information */}
diff --git a/components/NotificationSettings.tsx b/components/NotificationSettings.tsx
new file mode 100644
index 0000000..21404cd
--- /dev/null
+++ b/components/NotificationSettings.tsx
@@ -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 (
+
+
+
+
+ {isSubscribed ? : }
+ Push Notifications
+
+
+ Get notified when someone adds items, shares lists, or invites you to collaborate
+
+
+ {permission === 'denied' && (
+
+
+ Notifications are blocked. Please enable them in your browser settings.
+
+
+ )}
+
+ {error && (
+
+ )}
+
+ {isSubscribed && (
+
+
+ โ You'll receive notifications on this device
+
+
+ )}
+
+
+
+
+
+
+
๐ก Tip: Install this app to your home screen for the best notification experience
+
+
+ );
+}
diff --git a/hooks/usePushNotifications.ts b/hooks/usePushNotifications.ts
new file mode 100644
index 0000000..61fd08c
--- /dev/null
+++ b/hooks/usePushNotifications.ts
@@ -0,0 +1,170 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+export function usePushNotifications() {
+ const [permission, setPermission] = useState('default');
+ const [isSubscribed, setIsSubscribed] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(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;
+}
diff --git a/lib/actions.ts b/lib/actions.ts
index 733c1d7..c835d5d 100644
--- a/lib/actions.ts
+++ b/lib/actions.ts
@@ -12,6 +12,11 @@ import {
validateTags,
sanitizeString,
} from './validation'
+import {
+ notifyItemAdded,
+ notifyItemRemoved,
+ notifyItemCompleted,
+} from './notification-helpers'
// Retry utility for database operations
async function retryOperation(
@@ -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) {
diff --git a/lib/notification-helpers.ts b/lib/notification-helpers.ts
new file mode 100644
index 0000000..68827c8
--- /dev/null
+++ b/lib/notification-helpers.ts
@@ -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',
+ },
+ });
+}
diff --git a/lib/push-notifications.ts b/lib/push-notifications.ts
new file mode 100644
index 0000000..53a2134
--- /dev/null
+++ b/lib/push-notifications.ts
@@ -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;
+}
diff --git a/lib/sharing-actions.ts b/lib/sharing-actions.ts
index 3f37316..89b14c8 100644
--- a/lib/sharing-actions.ts
+++ b/lib/sharing-actions.ts
@@ -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) {
diff --git a/package.json b/package.json
index 193f9d9..ca34913 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 789e3df..2487679 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
diff --git a/public/sw.js b/public/sw.js
index d159c96..da07d0f 100644
--- a/public/sw.js
+++ b/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);
+ }
+ })
+ );
+});
diff --git a/scripts/generate-vapid-keys.js b/scripts/generate-vapid-keys.js
new file mode 100644
index 0000000..e40fd54
--- /dev/null
+++ b/scripts/generate-vapid-keys.js
@@ -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');