Add notifications for interest indicator

This commit is contained in:
MartinBraquet
2026-02-24 18:00:12 +01:00
parent df2473929a
commit 2b31ed3164
6 changed files with 193 additions and 157 deletions

View File

@@ -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 []
}
}

View File

@@ -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', 'Its 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)
}
}
}

View 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 []
}
}

View File

@@ -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."
}

View File

@@ -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": "Cest mutuel 🎉",
"notifications.connection.mutual_body": "Vous et {name} êtes tous deux intéressés par un(e) {type}. Commencez la conversation."
}

View File

@@ -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={<></>}
>