186 lines
4.7 KiB
TypeScript
186 lines
4.7 KiB
TypeScript
import webpush from 'web-push';
|
|
import { db } from './db';
|
|
import { notificationSubscription } from './db/schema';
|
|
import type { NotificationSubscriptionInsert } from './db/schema';
|
|
import { eq, and } from 'drizzle-orm';
|
|
import { nanoid } from 'nanoid';
|
|
import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from '$env/static/private';
|
|
|
|
// Initialize web-push with VAPID keys
|
|
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY || !VAPID_SUBJECT) {
|
|
throw new Error(
|
|
'VAPID keys are not configured. Please set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT in your environment variables.'
|
|
);
|
|
}
|
|
|
|
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
|
|
|
|
export interface PushSubscriptionData {
|
|
endpoint: string;
|
|
keys: {
|
|
p256dh: string;
|
|
auth: string;
|
|
};
|
|
}
|
|
|
|
export interface PushNotificationPayload {
|
|
title: string;
|
|
message: string;
|
|
data?: Record<string, unknown>;
|
|
icon?: string;
|
|
badge?: string;
|
|
tag?: string;
|
|
url?: string;
|
|
}
|
|
|
|
export class PushService {
|
|
/**
|
|
* Save a push subscription for a user
|
|
*/
|
|
async saveSubscription(
|
|
userId: string,
|
|
subscription: PushSubscriptionData,
|
|
userAgent?: string
|
|
): Promise<void> {
|
|
const subscriptionData: NotificationSubscriptionInsert = {
|
|
id: nanoid(),
|
|
userId,
|
|
endpoint: subscription.endpoint,
|
|
p256dhKey: subscription.keys.p256dh,
|
|
authKey: subscription.keys.auth,
|
|
userAgent: userAgent || null,
|
|
isActive: true
|
|
};
|
|
|
|
// Check if subscription already exists
|
|
const existing = await db
|
|
.select()
|
|
.from(notificationSubscription)
|
|
.where(eq(notificationSubscription.endpoint, subscription.endpoint))
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
// Update existing subscription
|
|
await db
|
|
.update(notificationSubscription)
|
|
.set({
|
|
p256dhKey: subscription.keys.p256dh,
|
|
authKey: subscription.keys.auth,
|
|
userAgent: userAgent || null,
|
|
isActive: true,
|
|
updatedAt: new Date()
|
|
})
|
|
.where(eq(notificationSubscription.endpoint, subscription.endpoint));
|
|
} else {
|
|
// Insert new subscription
|
|
await db.insert(notificationSubscription).values(subscriptionData);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a push subscription
|
|
*/
|
|
async removeSubscription(userId: string, endpoint: string): Promise<void> {
|
|
await db
|
|
.delete(notificationSubscription)
|
|
.where(
|
|
and(
|
|
eq(notificationSubscription.userId, userId),
|
|
eq(notificationSubscription.endpoint, endpoint)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all active subscriptions for a user
|
|
*/
|
|
async getUserSubscriptions(userId: string) {
|
|
return await db
|
|
.select()
|
|
.from(notificationSubscription)
|
|
.where(
|
|
and(
|
|
eq(notificationSubscription.userId, userId),
|
|
eq(notificationSubscription.isActive, true)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send push notification to a user's subscriptions
|
|
*/
|
|
async sendPushNotification(userId: string, payload: PushNotificationPayload): Promise<void> {
|
|
const subscriptions = await this.getUserSubscriptions(userId);
|
|
|
|
if (subscriptions.length === 0) {
|
|
console.log(`No active subscriptions found for user ${userId}`);
|
|
return;
|
|
}
|
|
|
|
const notificationPayload = JSON.stringify({
|
|
title: payload.title,
|
|
body: payload.message,
|
|
icon: payload.icon || '/logo.svg',
|
|
badge: payload.badge || '/logo.svg',
|
|
tag: payload.tag,
|
|
data: {
|
|
url: payload.url || '/',
|
|
...payload.data
|
|
}
|
|
});
|
|
|
|
const sendPromises = subscriptions.map(async (sub) => {
|
|
const pushSubscription: webpush.PushSubscription = {
|
|
endpoint: sub.endpoint,
|
|
keys: {
|
|
p256dh: sub.p256dhKey,
|
|
auth: sub.authKey
|
|
}
|
|
};
|
|
|
|
try {
|
|
await webpush.sendNotification(pushSubscription, notificationPayload);
|
|
} catch (error) {
|
|
console.error(`Failed to send push notification to ${sub.endpoint}:`, error);
|
|
|
|
// If the subscription is invalid (410 Gone or 404), deactivate it
|
|
if (error instanceof Error && 'statusCode' in error) {
|
|
const statusCode = (error as { statusCode: number }).statusCode;
|
|
if (statusCode === 410 || statusCode === 404) {
|
|
await db
|
|
.update(notificationSubscription)
|
|
.set({ isActive: false })
|
|
.where(eq(notificationSubscription.id, sub.id));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
await Promise.all(sendPromises);
|
|
}
|
|
|
|
/**
|
|
* Send push notification to multiple users
|
|
*/
|
|
async sendPushNotificationToUsers(
|
|
userIds: string[],
|
|
payload: PushNotificationPayload
|
|
): Promise<void> {
|
|
const sendPromises = userIds.map((userId) => this.sendPushNotification(userId, payload));
|
|
await Promise.all(sendPromises);
|
|
}
|
|
|
|
/**
|
|
* Deactivate a subscription
|
|
*/
|
|
async deactivateSubscription(endpoint: string): Promise<void> {
|
|
await db
|
|
.update(notificationSubscription)
|
|
.set({ isActive: false })
|
|
.where(eq(notificationSubscription.endpoint, endpoint));
|
|
}
|
|
}
|
|
|
|
export const pushService = new PushService();
|
|
export { VAPID_PUBLIC_KEY };
|