Files
Todo/lib/notification-helpers.ts
Paul Bailey 7cda58440f 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.
2025-12-07 01:22:16 +00:00

202 lines
4.5 KiB
TypeScript

'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',
},
});
}