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

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