- 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.
339 lines
7.6 KiB
Markdown
339 lines
7.6 KiB
Markdown
# 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.
|