feat:add Web Push notification system
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, integer, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -88,15 +88,62 @@ export const findComment = pgTable('find_comment', {
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the new tables
|
||||
// Notification system tables
|
||||
export const notification = pgTable('notification', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'friend_request', 'friend_accepted', 'find_liked', 'find_commented'
|
||||
title: text('title').notNull(),
|
||||
message: text('message').notNull(),
|
||||
data: jsonb('data'), // Additional context data (findId, friendId, etc.)
|
||||
isRead: boolean('is_read').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationSubscription = pgTable('notification_subscription', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
p256dhKey: text('p256dh_key').notNull(),
|
||||
authKey: text('auth_key').notNull(),
|
||||
userAgent: text('user_agent'),
|
||||
isActive: boolean('is_active').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const notificationPreferences = pgTable('notification_preferences', {
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
friendRequests: boolean('friend_requests').default(true),
|
||||
friendAccepted: boolean('friend_accepted').default(true),
|
||||
findLiked: boolean('find_liked').default(true),
|
||||
findCommented: boolean('find_commented').default(true),
|
||||
pushEnabled: boolean('push_enabled').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
// Type exports for the tables
|
||||
export type Find = typeof find.$inferSelect;
|
||||
export type FindMedia = typeof findMedia.$inferSelect;
|
||||
export type FindLike = typeof findLike.$inferSelect;
|
||||
export type FindComment = typeof findComment.$inferSelect;
|
||||
export type Friendship = typeof friendship.$inferSelect;
|
||||
export type Notification = typeof notification.$inferSelect;
|
||||
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
|
||||
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
|
||||
|
||||
export type FindInsert = typeof find.$inferInsert;
|
||||
export type FindMediaInsert = typeof findMedia.$inferInsert;
|
||||
export type FindLikeInsert = typeof findLike.$inferInsert;
|
||||
export type FindCommentInsert = typeof findComment.$inferInsert;
|
||||
export type FriendshipInsert = typeof friendship.$inferInsert;
|
||||
export type NotificationInsert = typeof notification.$inferInsert;
|
||||
export type NotificationSubscriptionInsert = typeof notificationSubscription.$inferInsert;
|
||||
export type NotificationPreferencesInsert = typeof notificationPreferences.$inferInsert;
|
||||
|
||||
165
src/lib/server/notifications.ts
Normal file
165
src/lib/server/notifications.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { db } from './db';
|
||||
import { notification, notificationPreferences } from './db/schema';
|
||||
import type { NotificationInsert } from './db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type NotificationType = 'friend_request' | 'friend_accepted' | 'find_liked' | 'find_commented';
|
||||
|
||||
export interface CreateNotificationData {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GetNotificationsOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Create a new notification record in the database
|
||||
*/
|
||||
async createNotification(data: CreateNotificationData): Promise<void> {
|
||||
const notificationData: NotificationInsert = {
|
||||
id: nanoid(),
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
data: data.data || null,
|
||||
isRead: false
|
||||
};
|
||||
|
||||
await db.insert(notification).values(notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has notifications enabled for a specific type
|
||||
*/
|
||||
async shouldNotify(userId: string, type: NotificationType): Promise<boolean> {
|
||||
const prefs = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// If no preferences exist, default to true
|
||||
if (prefs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pref = prefs[0];
|
||||
|
||||
// Check if push is enabled and specific notification type is enabled
|
||||
if (!pref.pushEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'friend_request':
|
||||
return pref.friendRequests ?? true;
|
||||
case 'friend_accepted':
|
||||
return pref.friendAccepted ?? true;
|
||||
case 'find_liked':
|
||||
return pref.findLiked ?? true;
|
||||
case 'find_commented':
|
||||
return pref.findCommented ?? true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user notifications with pagination and filtering
|
||||
*/
|
||||
async getUserNotifications(userId: string, options: GetNotificationsOptions = {}) {
|
||||
const { limit = 20, offset = 0, unreadOnly = false } = options;
|
||||
|
||||
let query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(eq(notification.userId, userId))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (unreadOnly) {
|
||||
query = db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)))
|
||||
.orderBy(desc(notification.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notifications as read
|
||||
*/
|
||||
async markAsRead(notificationIds: string[]): Promise<void> {
|
||||
for (const id of notificationIds) {
|
||||
await db
|
||||
.update(notification)
|
||||
.set({ isRead: true })
|
||||
.where(eq(notification.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification as read
|
||||
*/
|
||||
async markOneAsRead(notificationId: string): Promise<void> {
|
||||
await db
|
||||
.update(notification)
|
||||
.set({ isRead: true })
|
||||
.where(eq(notification.id, notificationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
async markAllAsRead(userId: string): Promise<void> {
|
||||
await db
|
||||
.update(notification)
|
||||
.set({ isRead: true })
|
||||
.where(eq(notification.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a user
|
||||
*/
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
const notifications = await db
|
||||
.select()
|
||||
.from(notification)
|
||||
.where(and(eq(notification.userId, userId), eq(notification.isRead, false)));
|
||||
|
||||
return notifications.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
async deleteNotification(notificationId: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(notification)
|
||||
.where(and(eq(notification.id, notificationId), eq(notification.userId, userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all notifications for a user
|
||||
*/
|
||||
async deleteAllNotifications(userId: string): Promise<void> {
|
||||
await db.delete(notification).where(eq(notification.userId, userId));
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
183
src/lib/server/push.ts
Normal file
183
src/lib/server/push.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user