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:
338
PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
Normal file
338
PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Push Notifications Integration Example
|
||||
|
||||
This shows exactly how to add notifications to your existing code.
|
||||
|
||||
## Step 1: Import the notification helpers
|
||||
|
||||
At the top of your action files, add:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
notifyItemAdded,
|
||||
notifyItemRemoved,
|
||||
notifyItemCompleted,
|
||||
notifyListInvitation,
|
||||
notifyInvitationAccepted,
|
||||
notifyRemovedFromList,
|
||||
} from '@/lib/notification-helpers';
|
||||
```
|
||||
|
||||
## Step 2: Add notification calls after actions
|
||||
|
||||
### Example: Adding an item
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function createItem(listId: string, data: any) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.create({
|
||||
data: {
|
||||
...data,
|
||||
listId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
return item;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function createItem(listId: string, data: any) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.create({
|
||||
data: {
|
||||
...data,
|
||||
listId,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Add this line to send notifications
|
||||
await notifyItemAdded(listId, item.title, session.user.id);
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
return item;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Deleting an item
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function deleteItem(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
await prisma.item.delete({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
revalidatePath('/');
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function deleteItem(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
// 🔔 Get item details before deleting
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
await prisma.item.delete({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
// 🔔 Send notification
|
||||
if (item) {
|
||||
await notifyItemRemoved(item.listId, item.title, session.user.id);
|
||||
}
|
||||
|
||||
revalidatePath('/');
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Toggling item completion
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function toggleItemComplete(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
const updated = await prisma.item.update({
|
||||
where: { id: itemId },
|
||||
data: { completed: !item?.completed },
|
||||
});
|
||||
|
||||
revalidatePath('/');
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function toggleItemComplete(itemId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
|
||||
const updated = await prisma.item.update({
|
||||
where: { id: itemId },
|
||||
data: { completed: !item?.completed },
|
||||
});
|
||||
|
||||
// 🔔 Notify when item is completed (not uncompleted)
|
||||
if (updated.completed) {
|
||||
await notifyItemCompleted(updated.listId, updated.title, session.user.id);
|
||||
}
|
||||
|
||||
revalidatePath('/');
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Inviting to a list
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function inviteToList(listId: string, email: string, permission: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.create({
|
||||
data: {
|
||||
listId,
|
||||
email,
|
||||
permission,
|
||||
inviterId: session.user.id,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function inviteToList(listId: string, email: string, permission: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.create({
|
||||
data: {
|
||||
listId,
|
||||
email,
|
||||
permission,
|
||||
inviterId: session.user.id,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Notify the invitee
|
||||
await notifyListInvitation(invitation.id, email);
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Accepting an invitation
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function acceptInvitation(invitationId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: 'accepted' },
|
||||
});
|
||||
|
||||
await prisma.collaborator.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
listId: invitation.listId,
|
||||
permission: invitation.permission,
|
||||
},
|
||||
});
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function acceptInvitation(invitationId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const invitation = await prisma.invitation.update({
|
||||
where: { id: invitationId },
|
||||
data: { status: 'accepted' },
|
||||
});
|
||||
|
||||
await prisma.collaborator.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
listId: invitation.listId,
|
||||
permission: invitation.permission,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Notify the person who sent the invitation
|
||||
await notifyInvitationAccepted(invitationId);
|
||||
|
||||
return invitation;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Removing a collaborator
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function removeCollaborator(listId: string, userId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
await prisma.collaborator.deleteMany({
|
||||
where: {
|
||||
listId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export async function removeCollaborator(listId: string, userId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
// 🔔 Get list details before removing
|
||||
const list = await prisma.list.findUnique({
|
||||
where: { id: listId },
|
||||
});
|
||||
|
||||
await prisma.collaborator.deleteMany({
|
||||
where: {
|
||||
listId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// 🔔 Notify the removed user
|
||||
if (list) {
|
||||
await notifyRemovedFromList(userId, list.title, session.user.id);
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Add NotificationSettings to Profile Page
|
||||
|
||||
In `app/profile/page.tsx`, add the component:
|
||||
|
||||
```typescript
|
||||
import NotificationSettings from '@/components/NotificationSettings';
|
||||
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Profile Settings</h1>
|
||||
|
||||
{/* Your existing profile components */}
|
||||
<ProfileForm />
|
||||
<PasswordChangeForm />
|
||||
|
||||
{/* 🔔 Add notification settings */}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## That's It!
|
||||
|
||||
The notification system will:
|
||||
- ✅ Automatically handle permission requests
|
||||
- ✅ Store subscriptions per user/device
|
||||
- ✅ Send notifications to all relevant users
|
||||
- ✅ Exclude the user who triggered the action
|
||||
- ✅ Clean up expired subscriptions
|
||||
- ✅ Work across multiple devices
|
||||
|
||||
## Optional: Error Handling
|
||||
|
||||
If you want to handle notification failures gracefully:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await notifyItemAdded(listId, item.title, session.user.id);
|
||||
} catch (error) {
|
||||
// Log but don't fail the action
|
||||
console.error('Failed to send notification:', error);
|
||||
}
|
||||
```
|
||||
|
||||
Notifications are non-critical, so it's fine to let them fail silently without affecting the main action.
|
||||
Reference in New Issue
Block a user