Release 202506042038
This commit is contained in:
@@ -6,7 +6,11 @@
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const typedSelf = self
|
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) => {
|
typedSelf.addEventListener('install', (event) => {
|
||||||
console.log('Service Worker installing')
|
console.log('Service Worker installing')
|
||||||
@@ -22,36 +26,59 @@ typedSelf.addEventListener('push', (event) => {
|
|||||||
console.log('Push event received:', event)
|
console.log('Push event received:', event)
|
||||||
|
|
||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
console.log('Push event but no data')
|
console.error('Push event but no data')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let notificationData
|
let title = 'New Notification'
|
||||||
try {
|
/** @type {CustomNotificationOptions} */
|
||||||
notificationData = event.data.json()
|
let options = {
|
||||||
} catch (error) {
|
body: 'You have a new notification',
|
||||||
console.error('Error parsing push data:', error)
|
lang: 'en-US',
|
||||||
notificationData = {
|
icon: '/favicon.svg',
|
||||||
title: 'New Notification',
|
badge: '/favicon.svg',
|
||||||
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 || {},
|
|
||||||
requireInteraction: false,
|
requireInteraction: false,
|
||||||
silent: 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) => {
|
typedSelf.addEventListener('notificationclick', (event) => {
|
||||||
@@ -59,7 +86,11 @@ typedSelf.addEventListener('notificationclick', (event) => {
|
|||||||
|
|
||||||
event.notification.close()
|
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(
|
event.waitUntil(
|
||||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { z } from 'astro/zod'
|
|||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { sendNotifications } from '../../lib/sendNotifications'
|
|
||||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||||
|
|
||||||
export const adminNotificationActions = {
|
export const adminNotificationActions = {
|
||||||
@@ -26,10 +25,8 @@ export const adminNotificationActions = {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const results = await sendNotifications(notifications.map((notification) => notification.id))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`,
|
message: `Created ${notifications.length.toString()} notifications.`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
|
|||||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||||
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
||||||
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
||||||
<label for={inputId}>{label}</label>
|
<label for={inputId} transition:persist>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
{required && '*'}
|
{required && '*'}
|
||||||
</legend>
|
</legend>
|
||||||
{!!descriptionLabel && (
|
{!!descriptionLabel && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
|
|||||||
|
|
||||||
import { makeCommentUrl } from './commentsWithReplies'
|
import { makeCommentUrl } from './commentsWithReplies'
|
||||||
|
|
||||||
|
import type { NotificationAction } from './webPush'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
export function makeNotificationTitle(
|
export function makeNotificationTitle(
|
||||||
@@ -244,7 +245,7 @@ export function makeNotificationContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeNotificationLink(
|
export function makeNotificationActions(
|
||||||
notification: Prisma.NotificationGetPayload<{
|
notification: Prisma.NotificationGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
type: true
|
type: true
|
||||||
@@ -286,47 +287,120 @@ export function makeNotificationLink(
|
|||||||
}
|
}
|
||||||
}>,
|
}>,
|
||||||
origin: string
|
origin: string
|
||||||
): string | null {
|
): NotificationAction[] {
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case 'TEST': {
|
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 'COMMENT_STATUS_CHANGE':
|
||||||
case 'REPLY_COMMENT_CREATED':
|
case 'REPLY_COMMENT_CREATED':
|
||||||
case 'COMMUNITY_NOTE_ADDED':
|
case 'COMMUNITY_NOTE_ADDED':
|
||||||
case 'ROOT_COMMENT_CREATED': {
|
case 'ROOT_COMMENT_CREATED': {
|
||||||
if (!notification.aboutComment) return null
|
if (!notification.aboutComment) return []
|
||||||
return makeCommentUrl({
|
return [
|
||||||
serviceSlug: notification.aboutComment.service.slug,
|
{
|
||||||
commentId: notification.aboutComment.id,
|
action: 'view',
|
||||||
origin,
|
title: 'View',
|
||||||
})
|
...iconNameAndUrl('ri:arrow-right-line'),
|
||||||
|
url: makeCommentUrl({
|
||||||
|
serviceSlug: notification.aboutComment.service.slug,
|
||||||
|
commentId: notification.aboutComment.id,
|
||||||
|
origin,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
case 'SUGGESTION_MESSAGE': {
|
case 'SUGGESTION_MESSAGE': {
|
||||||
if (!notification.aboutServiceSuggestionMessage) return null
|
if (!notification.aboutServiceSuggestionMessage) return []
|
||||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
|
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': {
|
case 'SUGGESTION_STATUS_CHANGE': {
|
||||||
if (!notification.aboutServiceSuggestionId) return null
|
if (!notification.aboutServiceSuggestionId) return []
|
||||||
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
|
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.
|
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||||
// case 'KARMA_UNLOCK': {
|
// case 'KARMA_UNLOCK': {
|
||||||
// return `${origin}/account#karma-unlocks`
|
// return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
|
||||||
// }
|
// }
|
||||||
case 'KARMA_CHANGE': {
|
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': {
|
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': {
|
case 'EVENT_CREATED': {
|
||||||
if (!notification.aboutEvent) return null
|
if (!notification.aboutEvent) return []
|
||||||
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
|
return [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
title: 'View',
|
||||||
|
...iconNameAndUrl('ri:arrow-right-line'),
|
||||||
|
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
|
||||||
if (!notification.aboutService) return null
|
if (!notification.aboutService) return []
|
||||||
return `${origin}/service/${notification.aboutService.slug}#verification`
|
return [
|
||||||
|
{
|
||||||
|
action: 'view',
|
||||||
|
title: 'View',
|
||||||
|
...iconNameAndUrl('ri:arrow-right-line'),
|
||||||
|
url: `${origin}/service/${notification.aboutService.slug}#verification`,
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iconUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||||
|
return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconNameAndUrl<T extends `${string}:${string}`>(iconName: T) {
|
||||||
|
return {
|
||||||
|
iconName,
|
||||||
|
icon: iconUrl(iconName),
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from 'astro/zod'
|
|||||||
import { Client } from 'pg'
|
import { Client } from 'pg'
|
||||||
|
|
||||||
import { zodParseJSON } from './json'
|
import { zodParseJSON } from './json'
|
||||||
import { sendNotifications } from './sendNotifications'
|
import { sendNotification } from './sendNotifications'
|
||||||
import { getServerEnvVariable } from './serverEnvVariables'
|
import { getServerEnvVariable } from './serverEnvVariables'
|
||||||
|
|
||||||
import type { AstroIntegration, HookParameters } from 'astro'
|
import type { AstroIntegration, HookParameters } from 'astro'
|
||||||
@@ -21,10 +21,10 @@ async function handleNotificationCreated(
|
|||||||
try {
|
try {
|
||||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||||
|
|
||||||
const results = await sendNotifications([notificationId], logger)
|
const results = await sendNotification(notificationId, logger)
|
||||||
|
|
||||||
logger.info(
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { sum } from 'lodash-es'
|
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
|
||||||
|
|
||||||
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
|
|
||||||
import { prisma } from './prisma'
|
import { prisma } from './prisma'
|
||||||
import { getServerEnvVariable } from './serverEnvVariables'
|
import { getServerEnvVariable } from './serverEnvVariables'
|
||||||
import { type NotificationData, sendPushNotification } from './webPush'
|
import { type NotificationPayload, sendPushNotification } from './webPush'
|
||||||
|
|
||||||
import type { AstroIntegrationLogger } from 'astro'
|
import type { AstroIntegrationLogger } from 'astro'
|
||||||
|
|
||||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||||
|
|
||||||
export async function sendNotifications(
|
export async function sendNotification(
|
||||||
notificationIds: number[],
|
notificationId: number,
|
||||||
logger: AstroIntegrationLogger | Console = console
|
logger: AstroIntegrationLogger | Console = console
|
||||||
) {
|
) {
|
||||||
const notifications = await prisma.notification.findMany({
|
const notification = await prisma.notification.findUnique({
|
||||||
where: { id: { in: notificationIds } },
|
where: { id: notificationId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
@@ -110,76 +108,58 @@ export async function sendNotifications(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (notifications.length < notificationIds.length) {
|
if (!notification) {
|
||||||
const missingNotificationIds = notificationIds.filter(
|
logger.error(`Notification with ID ${notificationId.toString()} not found`)
|
||||||
(id) => !notifications.some((notification) => notification.id === id)
|
return { success: 0, failure: 0, total: 0 }
|
||||||
)
|
|
||||||
logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const subscriptions = await prisma.pushSubscription.findMany({
|
||||||
notifications.map(async (notification) => {
|
where: { userId: notification.userId },
|
||||||
const subscriptions = await prisma.pushSubscription.findMany({
|
select: {
|
||||||
where: { userId: notification.userId },
|
id: true,
|
||||||
select: {
|
endpoint: true,
|
||||||
id: true,
|
p256dh: true,
|
||||||
endpoint: true,
|
auth: 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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
notificationPayload
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
// Remove invalid subscriptions
|
||||||
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
|
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||||
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
|
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
|
||||||
total: subscriptionResults.length,
|
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result.success
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscriptions: {
|
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
|
||||||
success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)),
|
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
|
||||||
failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)),
|
total: subscriptionResults.length,
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,19 @@ webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
|||||||
|
|
||||||
export { webpush }
|
export { webpush }
|
||||||
|
|
||||||
export type NotificationData = {
|
export type NotificationAction = {
|
||||||
|
action: string
|
||||||
title: string
|
title: string
|
||||||
body?: string
|
|
||||||
icon?: 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(
|
export async function sendPushNotification(
|
||||||
@@ -28,26 +35,13 @@ export async function sendPushNotification(
|
|||||||
auth: string
|
auth: string
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data: NotificationData
|
payload: NotificationPayload
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await webpush.sendNotification(
|
// NOTE: View sw.js to see how the notification is handled
|
||||||
subscription,
|
const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
|
||||||
JSON.stringify({
|
TTL: 24 * 60 * 60, // 24 hours
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return { success: true, result } as const
|
return { success: true, result } as const
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending push notification:', error)
|
console.error('Error sending push notification:', error)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes'
|
|||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
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 { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||||
@@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({
|
|||||||
typeInfo: getNotificationTypeInfo(notification.type),
|
typeInfo: getNotificationTypeInfo(notification.type),
|
||||||
title: makeNotificationTitle(notification, user),
|
title: makeNotificationTitle(notification, user),
|
||||||
content: makeNotificationContent(notification),
|
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"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<Icon name={notification.read ? 'ri:eye-close-line' : 'ri:eye-line'} class="size-4" />
|
<Icon name={notification.read ? 'ri:close-line' : 'ri:check-line'} class="size-4" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</form>
|
</form>
|
||||||
{notification.link && (
|
{notification.actions.map((action) => (
|
||||||
<a
|
<Tooltip
|
||||||
href={notification.link}
|
as="a"
|
||||||
|
href={action.url}
|
||||||
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"
|
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"
|
||||||
|
text={action.title}
|
||||||
|
position="left"
|
||||||
>
|
>
|
||||||
<Icon name="ri:arrow-right-line" class="size-4" />
|
<Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
|
||||||
<span class="sr-only">View details</span>
|
<span class="sr-only">{action.title}</span>
|
||||||
</a>
|
</Tooltip>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user