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:
@@ -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
201
lib/notification-helpers.ts
Normal 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
191
lib/push-notifications.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user