From 2b31ed3164f016438db93d98c3072ddf8a1ed7ba Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Tue, 24 Feb 2026 18:00:12 +0100 Subject: [PATCH] Add notifications for interest indicator --- backend/api/src/helpers/private-messages.ts | 153 +---------------- .../api/src/update-connection-interests.ts | 28 +++- backend/shared/src/mobile.ts | 157 ++++++++++++++++++ common/messages/de.json | 4 +- common/messages/fr.json | 4 +- web/components/notification-items.tsx | 4 +- 6 files changed, 193 insertions(+), 157 deletions(-) create mode 100644 backend/shared/src/mobile.ts diff --git a/backend/api/src/helpers/private-messages.ts b/backend/api/src/helpers/private-messages.ts index 4a4b4b34..7150734e 100644 --- a/backend/api/src/helpers/private-messages.ts +++ b/backend/api/src/helpers/private-messages.ts @@ -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 -} - -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 [] - } -} diff --git a/backend/api/src/update-connection-interests.ts b/backend/api/src/update-connection-interests.ts index de4dbf68..098acc42 100644 --- a/backend/api/src/update-connection-interests.ts +++ b/backend/api/src/update-connection-interests.ts @@ -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) } } } diff --git a/backend/shared/src/mobile.ts b/backend/shared/src/mobile.ts new file mode 100644 index 00000000..fd9df83c --- /dev/null +++ b/backend/shared/src/mobile.ts @@ -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 +} + +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 [] + } +} diff --git a/common/messages/de.json b/common/messages/de.json index cf41e573..3b3b9ef7 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -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." } diff --git a/common/messages/fr.json b/common/messages/fr.json index f509e07b..921cd267 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -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." } diff --git a/web/components/notification-items.tsx b/web/components/notification-items.tsx index 22bb634a..356bc1ad 100644 --- a/web/components/notification-items.tsx +++ b/web/components/notification-items.tsx @@ -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 ( } + icon={} link={`/${sourceUserUsername}`} subtitle={<>} >