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
{!!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}
+
+ ))}