- 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.
192 lines
5.1 KiB
TypeScript
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;
|
|
}
|