diff --git a/web/public/sw.js b/web/public/sw.js index fdac2c6..358b632 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -6,7 +6,11 @@ // @ts-expect-error const typedSelf = self -const CACHE_NAME = 'kycnot-sw-push-notifications-v1' +const CACHE_NAME = 'kycnot-sw-push-notifications-v2' + +/** @typedef {import('../src/lib/webPush').NotificationPayload} NotificationPayload */ +/** @typedef {{defaultActionUrl: string, payload: NotificationPayload | null}} NotificationData */ +/** @typedef {NotificationOptions & { actions: { action: string; title: string; icon?: string }[], timestamp: number, data: NotificationData } } CustomNotificationOptions */ typedSelf.addEventListener('install', (event) => { console.log('Service Worker installing') @@ -22,36 +26,59 @@ typedSelf.addEventListener('push', (event) => { console.log('Push event received:', event) if (!event.data) { - console.log('Push event but no data') + console.error('Push event but no data') return } - let notificationData - try { - notificationData = event.data.json() - } catch (error) { - console.error('Error parsing push data:', error) - notificationData = { - title: 'New Notification', - options: { - body: event.data.text() || 'You have a new notification', - }, - } - } - - const { title, options } = notificationData - - const notificationOptions = { - body: options.body || '', - icon: options.icon || '/favicon.svg', - badge: options.badge || '/favicon.svg', - data: options.data || {}, + let title = 'New Notification' + /** @type {CustomNotificationOptions} */ + let options = { + body: 'You have a new notification', + lang: 'en-US', + icon: '/favicon.svg', + badge: '/favicon.svg', requireInteraction: false, silent: false, - ...options, + actions: [ + { + action: 'view', + title: 'View', + icon: 'https://api.iconify.design/ri/arrow-right-line.svg', + }, + ], + timestamp: Date.now(), + data: { + defaultActionUrl: '/notifications', + payload: null, + }, } - event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions)) + try { + /** @type {NotificationPayload} */ + const rawData = event.data.json() + if (typeof rawData !== 'object' || rawData === null) throw new Error('Invalid push data, not an object') + if (!('title' in rawData) || typeof rawData.title !== 'string') + throw new Error('Invalid push data, no title') + title = rawData.title + + options = { + ...options, + body: rawData.body || undefined, + actions: rawData.actions.map((action) => ({ + action: action.action, + title: action.title, + icon: action.icon, + })), + data: { + ...options.data, + payload: rawData, + }, + } + } catch (error) { + console.error('Error parsing push data:', error) + } + + event.waitUntil(typedSelf.registration.showNotification(title, options)) }) typedSelf.addEventListener('notificationclick', (event) => { @@ -59,7 +86,11 @@ typedSelf.addEventListener('notificationclick', (event) => { event.notification.close() - const url = event.notification.data?.url || '/' + /** @type {NotificationData} */ + const data = event.notification.data + + // @ts-expect-error I already use optional chaining + const url = data.payload?.[event.action]?.url || data.defaultActionUrl event.waitUntil( typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => { diff --git a/web/src/actions/admin/notification.ts b/web/src/actions/admin/notification.ts index 4d4a29c..ccd8eb6 100644 --- a/web/src/actions/admin/notification.ts +++ b/web/src/actions/admin/notification.ts @@ -2,7 +2,6 @@ import { z } from 'astro/zod' import { defineProtectedAction } from '../../lib/defineProtectedAction' import { prisma } from '../../lib/prisma' -import { sendNotifications } from '../../lib/sendNotifications' import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils' export const adminNotificationActions = { @@ -26,10 +25,8 @@ export const adminNotificationActions = { select: { id: true }, }) - const results = await sendNotifications(notifications.map((notification) => notification.id)) - return { - message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`, + message: `Created ${notifications.length.toString()} notifications.`, } }, }), diff --git a/web/src/components/InputWrapper.astro b/web/src/components/InputWrapper.astro index c75d158..a3949e8 100644 --- a/web/src/components/InputWrapper.astro +++ b/web/src/components/InputWrapper.astro @@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
{icon && } - + {required && '*'} {!!descriptionLabel && ( diff --git a/web/src/lib/notifications.ts b/web/src/lib/notifications.ts index f13a45e..ca6b09a 100644 --- a/web/src/lib/notifications.ts +++ b/web/src/lib/notifications.ts @@ -7,6 +7,7 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu import { makeCommentUrl } from './commentsWithReplies' +import type { NotificationAction } from './webPush' import type { Prisma } from '@prisma/client' export function makeNotificationTitle( @@ -244,7 +245,7 @@ export function makeNotificationContent( } } -export function makeNotificationLink( +export function makeNotificationActions( notification: Prisma.NotificationGetPayload<{ select: { type: true @@ -286,47 +287,120 @@ export function makeNotificationLink( } }>, origin: string -): string | null { +): NotificationAction[] { switch (notification.type) { case 'TEST': { - return `${origin}/notifications` + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: `${origin}/notifications`, + }, + { + action: 'profile', + title: 'Profile', + ...iconNameAndUrl('ri:user-line'), + url: `${origin}/account`, + }, + ] } case 'COMMENT_STATUS_CHANGE': case 'REPLY_COMMENT_CREATED': case 'COMMUNITY_NOTE_ADDED': case 'ROOT_COMMENT_CREATED': { - if (!notification.aboutComment) return null - return makeCommentUrl({ - serviceSlug: notification.aboutComment.service.slug, - commentId: notification.aboutComment.id, - origin, - }) + if (!notification.aboutComment) return [] + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: makeCommentUrl({ + serviceSlug: notification.aboutComment.service.slug, + commentId: notification.aboutComment.id, + origin, + }), + }, + ] } case 'SUGGESTION_MESSAGE': { - if (!notification.aboutServiceSuggestionMessage) return null - return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}` + if (!notification.aboutServiceSuggestionMessage) return [] + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`, + }, + ] } case 'SUGGESTION_STATUS_CHANGE': { - if (!notification.aboutServiceSuggestionId) return null - return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}` + if (!notification.aboutServiceSuggestionId) return [] + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`, + }, + ] } // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code. // case 'KARMA_UNLOCK': { - // return `${origin}/account#karma-unlocks` + // return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }] // } case 'KARMA_CHANGE': { - return `${origin}/account#karma-transactions` + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: `${origin}/account#karma-transactions`, + }, + ] } case 'ACCOUNT_STATUS_CHANGE': { - return `${origin}/account#account-status` + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: `${origin}/account#account-status`, + }, + ] } case 'EVENT_CREATED': { - if (!notification.aboutEvent) return null - return `${origin}/service/${notification.aboutEvent.service.slug}#events` + if (!notification.aboutEvent) return [] + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: `${origin}/service/${notification.aboutEvent.service.slug}#events`, + }, + ] } case 'SERVICE_VERIFICATION_STATUS_CHANGE': { - if (!notification.aboutService) return null - return `${origin}/service/${notification.aboutService.slug}#verification` + if (!notification.aboutService) return [] + return [ + { + action: 'view', + title: 'View', + ...iconNameAndUrl('ri:arrow-right-line'), + url: `${origin}/service/${notification.aboutService.slug}#verification`, + }, + ] } } } + +function iconUrl(iconName: T) { + return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const +} + +function iconNameAndUrl(iconName: T) { + return { + iconName, + icon: iconUrl(iconName), + } as const +} diff --git a/web/src/lib/postgresListenerIntegration.ts b/web/src/lib/postgresListenerIntegration.ts index 179de52..41b985f 100644 --- a/web/src/lib/postgresListenerIntegration.ts +++ b/web/src/lib/postgresListenerIntegration.ts @@ -2,7 +2,7 @@ import { z } from 'astro/zod' import { Client } from 'pg' import { zodParseJSON } from './json' -import { sendNotifications } from './sendNotifications' +import { sendNotification } from './sendNotifications' import { getServerEnvVariable } from './serverEnvVariables' import type { AstroIntegration, HookParameters } from 'astro' @@ -21,10 +21,10 @@ async function handleNotificationCreated( try { logger.info(`Processing notification with ID: ${String(notificationId)}`) - const results = await sendNotifications([notificationId], logger) + const results = await sendNotification(notificationId, logger) logger.info( - `Sent push notifications for notification ${String(notificationId)} to ${String(results.subscriptions.success)} devices, ${String(results.subscriptions.failure)} failed` + `Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed` ) } catch (error) { logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`) diff --git a/web/src/lib/sendNotifications.ts b/web/src/lib/sendNotifications.ts index 2c1890c..6d7711c 100644 --- a/web/src/lib/sendNotifications.ts +++ b/web/src/lib/sendNotifications.ts @@ -1,20 +1,18 @@ -import { sum } from 'lodash-es' - -import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications' +import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications' import { prisma } from './prisma' import { getServerEnvVariable } from './serverEnvVariables' -import { type NotificationData, sendPushNotification } from './webPush' +import { type NotificationPayload, sendPushNotification } from './webPush' import type { AstroIntegrationLogger } from 'astro' const SITE_URL = getServerEnvVariable('SITE_URL') -export async function sendNotifications( - notificationIds: number[], +export async function sendNotification( + notificationId: number, logger: AstroIntegrationLogger | Console = console ) { - const notifications = await prisma.notification.findMany({ - where: { id: { in: notificationIds } }, + const notification = await prisma.notification.findUnique({ + where: { id: notificationId }, select: { id: true, type: true, @@ -110,76 +108,58 @@ export async function sendNotifications( }, }) - if (notifications.length < notificationIds.length) { - const missingNotificationIds = notificationIds.filter( - (id) => !notifications.some((notification) => notification.id === id) - ) - logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`) + if (!notification) { + logger.error(`Notification with ID ${notificationId.toString()} not found`) + return { success: 0, failure: 0, total: 0 } } - const results = await Promise.allSettled( - notifications.map(async (notification) => { - const subscriptions = await prisma.pushSubscription.findMany({ - where: { userId: notification.userId }, - select: { - id: true, - endpoint: true, - p256dh: true, - auth: true, + const subscriptions = await prisma.pushSubscription.findMany({ + where: { userId: notification.userId }, + select: { + id: true, + endpoint: true, + p256dh: true, + auth: true, + }, + }) + + if (subscriptions.length === 0) { + logger.info(`No push subscriptions found for user ${notification.user.name}`) + return { success: 0, failure: 0, total: 0 } + } + + const notificationPayload = { + title: makeNotificationTitle(notification, notification.user), + body: makeNotificationContent(notification), + actions: makeNotificationActions(notification, SITE_URL), + } satisfies NotificationPayload + + const subscriptionResults = await Promise.allSettled( + subscriptions.map(async (subscription) => { + const result = await sendPushNotification( + { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + }, }, - }) - - if (subscriptions.length === 0) { - logger.info(`No push subscriptions found for user ${notification.user.name}`) - return - } - const notificationData = { - title: makeNotificationTitle(notification, notification.user), - body: makeNotificationContent(notification) ?? undefined, - url: makeNotificationLink(notification, SITE_URL) ?? undefined, - } satisfies NotificationData - - const subscriptionResults = await Promise.allSettled( - subscriptions.map(async (subscription) => { - const result = await sendPushNotification( - { - endpoint: subscription.endpoint, - keys: { - p256dh: subscription.p256dh, - auth: subscription.auth, - }, - }, - notificationData - ) - - // Remove invalid subscriptions - if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) { - await prisma.pushSubscription.delete({ where: { id: subscription.id } }) - logger.info(`Removed invalid subscription for user ${notification.user.name}`) - } - - return result.success - }) + notificationPayload ) - return { - success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length, - failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length, - total: subscriptionResults.length, + // Remove invalid subscriptions + if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) { + await prisma.pushSubscription.delete({ where: { id: subscription.id } }) + logger.info(`Removed invalid subscription for user ${notification.user.name}`) } + + return result.success }) ) return { - subscriptions: { - success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)), - failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)), - total: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.total) ?? 0)), - }, - notifications: { - success: results.filter((r) => r.status === 'fulfilled').length, - failure: results.filter((r) => r.status !== 'fulfilled').length, - total: results.length, - }, + success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length, + failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length, + total: subscriptionResults.length, } } diff --git a/web/src/lib/webPush.ts b/web/src/lib/webPush.ts index 9ccade2..d36b440 100644 --- a/web/src/lib/webPush.ts +++ b/web/src/lib/webPush.ts @@ -12,12 +12,19 @@ webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY) export { webpush } -export type NotificationData = { +export type NotificationAction = { + action: string title: string - body?: string icon?: string - badge?: string - url?: string + + url: string | null + iconName?: string +} + +export type NotificationPayload = { + title: string + body: string | null + actions: NotificationAction[] } export async function sendPushNotification( @@ -28,26 +35,13 @@ export async function sendPushNotification( auth: string } }, - data: NotificationData + payload: NotificationPayload ) { try { - const result = await webpush.sendNotification( - subscription, - JSON.stringify({ - title: data.title, - options: { - body: data.body, - icon: data.icon ?? '/favicon.svg', - badge: data.badge ?? '/favicon.svg', - data: { - url: data.url, - }, - }, - }), - { - TTL: 24 * 60 * 60, // 24 hours - } - ) + // NOTE: View sw.js to see how the notification is handled + const result = await webpush.sendNotification(subscription, JSON.stringify(payload), { + TTL: 24 * 60 * 60, // 24 hours + }) return { success: true, result } as const } catch (error) { console.error('Error sending push notification:', error) diff --git a/web/src/pages/notifications.astro b/web/src/pages/notifications.astro index e487345..855d89f 100644 --- a/web/src/pages/notifications.astro +++ b/web/src/pages/notifications.astro @@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes' import BaseLayout from '../layouts/BaseLayout.astro' import { cn } from '../lib/cn' import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences' -import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from '../lib/notifications' +import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from '../lib/notifications' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { prisma } from '../lib/prisma' import { makeLoginUrl } from '../lib/redirectUrls' @@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({ typeInfo: getNotificationTypeInfo(notification.type), title: makeNotificationTitle(notification, user), content: makeNotificationContent(notification), - link: makeNotificationLink(notification, Astro.url.origin), + actions: makeNotificationActions(notification, Astro.url.origin), })) --- @@ -285,18 +285,21 @@ const notifications = dbNotifications.map((notification) => ({ type="submit" class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200" > - + - {notification.link && ( - ( + - - View details - - )} + + {action.title} + + ))}