mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-24 17:41:27 -04:00
Add notifications for interest indicator
This commit is contained in:
@@ -8,16 +8,14 @@ import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import {sendNewMessageEmail} from 'email/functions/helpers'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {TokenMessage} from 'firebase-admin/lib/messaging/messaging-api'
|
||||
import {first} from 'lodash'
|
||||
import {track} from 'shared/analytics'
|
||||
import {encryptMessage} from 'shared/encryption'
|
||||
import {sendMobileNotifications, sendWebNotifications} from 'shared/mobile'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
import {SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getPrivateUser, getUser} from 'shared/utils'
|
||||
import {broadcast} from 'shared/websockets/server'
|
||||
import webPush from 'web-push'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
@@ -218,152 +216,3 @@ const createNewMessageNotification = async (fromUser: User, toUser: User, channe
|
||||
if (!privateUser) return
|
||||
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
||||
}
|
||||
|
||||
async function sendWebNotifications(pg: SupabaseDirectClient, userId: string, payload: string) {
|
||||
webPush.setVapidDetails(
|
||||
'mailto:hello@compassmeet.com',
|
||||
process.env.VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!,
|
||||
)
|
||||
// Retrieve subscription from the database
|
||||
const subscriptions = await getSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
console.log('Sending notification to:', subscription.endpoint, payload)
|
||||
await webPush.sendNotification(subscription, payload)
|
||||
} catch (err: any) {
|
||||
console.log('Failed to send notification', err)
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
console.warn('Removing expired subscription', subscription.endpoint)
|
||||
await removeSubscription(pg, subscription.endpoint, userId)
|
||||
} else {
|
||||
console.error('Push failed', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select endpoint, keys
|
||||
from push_subscriptions
|
||||
where user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions.map((sub) => ({
|
||||
endpoint: sub.endpoint,
|
||||
keys: sub.keys,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSubscription(pg: SupabaseDirectClient, endpoint: any, userId: string) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions
|
||||
WHERE endpoint = $1
|
||||
AND user_id = $2`,
|
||||
[endpoint, userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function removeMobileSubscription(pg: SupabaseDirectClient, token: any, userId: string) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions_mobile
|
||||
WHERE token = $1
|
||||
AND user_id = $2`,
|
||||
[token, userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function sendMobileNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
await sendPushToToken(pg, userId, subscription.token, payload)
|
||||
}
|
||||
}
|
||||
|
||||
interface PushPayload {
|
||||
title: string
|
||||
body: string
|
||||
url: string
|
||||
data?: Record<string, string>
|
||||
}
|
||||
|
||||
export async function sendPushToToken(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
token: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const message: TokenMessage = {
|
||||
token,
|
||||
android: {
|
||||
notification: {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
endpoint: payload.url,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
// Fine to create at each call, as it's a cached singleton
|
||||
const fcm = admin.messaging()
|
||||
console.log('Sending notification to:', token, message)
|
||||
const response = await fcm.send(message)
|
||||
console.log('Push sent successfully:', response)
|
||||
return response
|
||||
} catch (err: unknown) {
|
||||
// Check if it's a Firebase Messaging error
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const firebaseError = err as {code: string; message: string}
|
||||
console.warn('Firebase error:', firebaseError.code, firebaseError.message)
|
||||
|
||||
// Handle specific error cases here if needed
|
||||
// For example, if token is no longer valid:
|
||||
if (
|
||||
firebaseError.code === 'messaging/registration-token-not-registered' ||
|
||||
firebaseError.code === 'messaging/invalid-argument'
|
||||
) {
|
||||
console.warn('Removing invalid FCM token')
|
||||
await removeMobileSubscription(pg, token, userId)
|
||||
}
|
||||
} else {
|
||||
console.error('Unknown error:', err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
export async function getMobileSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select token
|
||||
from push_subscriptions_mobile
|
||||
where user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
||||
import {createT} from 'shared/locale'
|
||||
import {sendMobileNotifications, sendWebNotifications} from 'shared/mobile'
|
||||
import {getProfile} from 'shared/profiles/supabase'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||
@@ -60,8 +62,32 @@ export const updateConnectionInterests: APIHandler<'update-connection-interest'>
|
||||
},
|
||||
}
|
||||
await insertNotificationToSupabase(notification, pg)
|
||||
}
|
||||
|
||||
// Send it to mobile as well
|
||||
const t = createT(targetPrivateUser.locale)
|
||||
const type = t(`profile.relationship.${connectionType}`, connectionType).toLowerCase()
|
||||
|
||||
const payload = {
|
||||
title: t('notifications.connection.mutual_title', 'It’s mutual 🎉'),
|
||||
body: t(
|
||||
'notifications.connection.mutual_body',
|
||||
'You and {name} are both interested in a {type}. Start the conversation.',
|
||||
{
|
||||
name: currentUser.name,
|
||||
type,
|
||||
},
|
||||
),
|
||||
url: `/${currentUser.username}`,
|
||||
}
|
||||
try {
|
||||
await sendWebNotifications(pg, targetUserId, JSON.stringify(payload))
|
||||
} catch (err) {
|
||||
console.error('Failed to send web notification:', err)
|
||||
}
|
||||
try {
|
||||
await sendMobileNotifications(pg, targetUserId, payload)
|
||||
} catch (err) {
|
||||
console.error('Failed to send mobile notification:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
157
backend/shared/src/mobile.ts
Normal file
157
backend/shared/src/mobile.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import {TokenMessage} from 'firebase-admin/lib/messaging/messaging-api'
|
||||
import {SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import webPush from 'web-push'
|
||||
|
||||
export async function sendWebNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
payload: string,
|
||||
) {
|
||||
webPush.setVapidDetails(
|
||||
'mailto:hello@compassmeet.com',
|
||||
process.env.VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!,
|
||||
)
|
||||
// Retrieve subscription from the database
|
||||
const subscriptions = await getSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
console.log('Sending notification to:', subscription.endpoint, payload)
|
||||
await webPush.sendNotification(subscription, payload)
|
||||
} catch (err: any) {
|
||||
console.log('Failed to send notification', err)
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
console.warn('Removing expired subscription', subscription.endpoint)
|
||||
await removeSubscription(pg, subscription.endpoint, userId)
|
||||
} else {
|
||||
console.error('Push failed', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select endpoint, keys
|
||||
from push_subscriptions
|
||||
where user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions.map((sub) => ({
|
||||
endpoint: sub.endpoint,
|
||||
keys: sub.keys,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSubscription(pg: SupabaseDirectClient, endpoint: any, userId: string) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions
|
||||
WHERE endpoint = $1
|
||||
AND user_id = $2`,
|
||||
[endpoint, userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function removeMobileSubscription(pg: SupabaseDirectClient, token: any, userId: string) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions_mobile
|
||||
WHERE token = $1
|
||||
AND user_id = $2`,
|
||||
[token, userId],
|
||||
)
|
||||
}
|
||||
|
||||
export async function sendMobileNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
await sendPushToToken(pg, userId, subscription.token, payload)
|
||||
}
|
||||
}
|
||||
|
||||
interface PushPayload {
|
||||
title: string
|
||||
body: string
|
||||
url: string
|
||||
data?: Record<string, string>
|
||||
}
|
||||
|
||||
export async function sendPushToToken(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
token: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const message: TokenMessage = {
|
||||
token,
|
||||
android: {
|
||||
notification: {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
endpoint: payload.url,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
// Fine to create at each call, as it's a cached singleton
|
||||
const fcm = admin.messaging()
|
||||
console.log('Sending notification to:', token, message)
|
||||
const response = await fcm.send(message)
|
||||
console.log('Push sent successfully:', response)
|
||||
return response
|
||||
} catch (err: unknown) {
|
||||
// Check if it's a Firebase Messaging error
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const firebaseError = err as {code: string; message: string}
|
||||
console.warn('Firebase error:', firebaseError.code, firebaseError.message)
|
||||
|
||||
// Handle specific error cases here if needed
|
||||
// For example, if token is no longer valid:
|
||||
if (
|
||||
firebaseError.code === 'messaging/registration-token-not-registered' ||
|
||||
firebaseError.code === 'messaging/invalid-argument'
|
||||
) {
|
||||
console.warn('Removing invalid FCM token')
|
||||
await removeMobileSubscription(pg, token, userId)
|
||||
}
|
||||
} else {
|
||||
console.error('Unknown error:', err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
export async function getMobileSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select token
|
||||
from push_subscriptions_mobile
|
||||
where user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1235,5 +1235,7 @@
|
||||
"profile.connect.not_both_open": "Sie sind nicht beide für einen {type} offen",
|
||||
"profile.connect.mutual": "Gegenseitig",
|
||||
"profile.connect.interest_indicators_disabled": "{user} hat Interessenssignale deaktiviert",
|
||||
"profile.connect.tips": "- Wählen Sie den Verbindungstyp, für den Sie offen sind.\n- Sie sehen dies nicht, es sei denn, sie wählen denselben Typ wie Sie.\n- Wenn Sie beide denselben Typ wählen, werden Sie beide benachrichtigt."
|
||||
"profile.connect.tips": "- Wählen Sie den Verbindungstyp, für den Sie offen sind.\n- Sie sehen dies nicht, es sei denn, sie wählen denselben Typ wie Sie.\n- Wenn Sie beide denselben Typ wählen, werden Sie beide benachrichtigt.",
|
||||
"notifications.connection.mutual_title": "Es ist gegenseitig 🎉",
|
||||
"notifications.connection.mutual_body": "Du und {name} sind beide an einem {type} interessiert. Beginnen Sie das Gespräch."
|
||||
}
|
||||
|
||||
@@ -1234,5 +1234,7 @@
|
||||
"profile.connect.not_both_open": "Vous n'êtes pas tous les deux ouverts à un {type}",
|
||||
"profile.connect.mutual": "Mutuel",
|
||||
"profile.connect.interest_indicators_disabled": "{user} a désactivé les indicateurs d'intérêt",
|
||||
"profile.connect.tips": "- Vous choisissez le type de connexion auquel vous êtes ouvert.\n- Ils ne verront pas ceci à moins qu'ils ne choisissent le même type que vous.\n- Si vous choisissez tous les deux le même type, vous serez tous les deux notifiés."
|
||||
"profile.connect.tips": "- Vous choisissez le type de connexion auquel vous êtes ouvert.\n- Ils ne verront pas ceci à moins qu'ils ne choisissent le même type que vous.\n- Si vous choisissez tous les deux le même type, vous serez tous les deux notifiés.",
|
||||
"notifications.connection.mutual_title": "C’est mutuel 🎉",
|
||||
"notifications.connection.mutual_body": "Vous et {name} êtes tous deux intéressés par un(e) {type}. Commencez la conversation."
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export function ConnectionInterestMatchNotification(props: {
|
||||
const {sourceUserName, sourceUserUsername, sourceText} = notification
|
||||
const t = useT()
|
||||
const connectionType = notification.data?.connectionType || sourceText
|
||||
const type = t(`profile.relationship.${connectionType}`, connectionType)
|
||||
const type = t(`profile.relationship.${connectionType}`, connectionType).toLowerCase()
|
||||
|
||||
return (
|
||||
<NotificationFrame
|
||||
@@ -246,7 +246,7 @@ export function ConnectionInterestMatchNotification(props: {
|
||||
isChildOfGroup={isChildOfGroup}
|
||||
highlighted={highlighted}
|
||||
setHighlighted={setHighlighted}
|
||||
icon={<AvatarNotificationIcon notification={notification} symbol={'💕'} />}
|
||||
icon={<AvatarNotificationIcon notification={notification} />}
|
||||
link={`/${sourceUserUsername}`}
|
||||
subtitle={<></>}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user