Files
Todo/lib/push-notifications.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

192 lines
5.1 KiB
TypeScript

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