From f1676c52f06d70f674ead24610e35c5a591332fa Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 27 Oct 2025 16:12:51 +0100 Subject: [PATCH] Add mobile push notifications --- backend/api/src/app.ts | 2 + backend/api/src/helpers/private-messages.ts | 202 ++++++++++++------ backend/api/src/save-subscription-mobile.ts | 32 +++ .../supabase/push_subscriptions_mobile.sql | 18 ++ common/src/api/schema.ts | 11 + package.json | 1 + web/lib/service/android-push.ts | 34 +++ .../service/{notifications.ts => web-push.ts} | 15 +- web/pages/_app.tsx | 6 +- yarn.lock | 5 + 10 files changed, 255 insertions(+), 71 deletions(-) create mode 100644 backend/api/src/save-subscription-mobile.ts create mode 100644 backend/supabase/push_subscriptions_mobile.sql create mode 100644 web/lib/service/android-push.ts rename web/lib/service/{notifications.ts => web-push.ts} (81%) diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 6243fbf9..61f112ef 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -68,6 +68,7 @@ import {getUser} from "api/get-user"; import {IS_LOCAL} from "common/envs/constants"; import {localSendTestEmail} from "api/test"; import path from "node:path"; +import {saveSubscriptionMobile} from "api/save-subscription-mobile"; // const corsOptions: CorsOptions = { // origin: ['*'], // Only allow requests from this domain @@ -354,6 +355,7 @@ const handlers: { [k in APIPath]: APIHandler } = { 'get-messages-count': getMessagesCount, 'set-last-online-time': setLastOnlineTime, 'save-subscription': saveSubscription, + 'save-subscription-mobile': saveSubscriptionMobile, 'create-bookmarked-search': createBookmarkedSearch, 'delete-bookmarked-search': deleteBookmarkedSearch, } diff --git a/backend/api/src/helpers/private-messages.ts b/backend/api/src/helpers/private-messages.ts index d5d20682..9e8dd474 100644 --- a/backend/api/src/helpers/private-messages.ts +++ b/backend/api/src/helpers/private-messages.ts @@ -13,9 +13,12 @@ import {sendNewMessageEmail} from 'email/functions/helpers' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import timezone from 'dayjs/plugin/timezone' -import webPush from 'web-push'; -import {parseJsonContentToText} from "common/util/parse"; -import {encryptMessage} from "shared/encryption"; +import webPush from 'web-push' +import {parseJsonContentToText} from "common/util/parse" +import {encryptMessage} from "shared/encryption" +import * as admin from 'firebase-admin' + +const fcm = admin.messaging() dayjs.extend(utc) dayjs.extend(timezone) @@ -29,17 +32,17 @@ export const leaveChatContent = (userName: string) => ({ }, ], }) -export const joinChatContent = (userName: string) => { - return { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [{text: `${userName} joined the chat!`, type: 'text'}], - }, - ], - } -} +// export const joinChatContent = (userName: string) => { +// return { +// type: 'doc', +// content: [ +// { +// type: 'paragraph', +// content: [{text: `${userName} joined the chat!`, type: 'text'}], +// }, +// ], +// } +// } export const insertPrivateMessage = async ( content: Json, @@ -48,8 +51,8 @@ export const insertPrivateMessage = async ( visibility: ChatVisibility, pg: SupabaseDirectClient ) => { - const plaintext = JSON.stringify(content); - const {ciphertext, iv, tag} = encryptMessage(plaintext); + const plaintext = JSON.stringify(content) + const {ciphertext, iv, tag} = encryptMessage(plaintext) const lastMessage = await pg.one( `insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility) values ($1, $2, $3, $4, $5, $6) @@ -134,7 +137,7 @@ export const createPrivateUserMessageMain = async ( void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg) .catch((err) => { console.error('notifyOtherUserInChannelIfInactive failed', err) - }); + }) track(creator.id, 'send private message', { channelId, @@ -162,49 +165,24 @@ const notifyOtherUserInChannelIfInactive = async ( // We're only sending notifs for 1:1 channels if (!otherUserIds || otherUserIds.length > 1) return - const otherUserId = first(otherUserIds) - if (!otherUserId) return + const receiverId = first(otherUserIds)?.user_id + if (!receiverId) return // TODO: notification only for active user - const otherUser = await getUser(otherUserId.user_id) - console.debug('otherUser:', otherUser) - if (!otherUser) return + const receiver = await getUser(receiverId) + console.debug('receiver:', receiver) + if (!receiver) return - // Push notif - webPush.setVapidDetails( - 'mailto:hello@compassmeet.com', - process.env.VAPID_PUBLIC_KEY!, - process.env.VAPID_PRIVATE_KEY! - ); + // Push notifs const textContent = parseJsonContentToText(content) - // Retrieve subscription from the database - const subscriptions = await getSubscriptionsFromDB(otherUser.id, pg); - for (const subscription of subscriptions) { - try { - const payload = JSON.stringify({ - title: `${creator.name}`, - body: textContent, - url: `/messages/${channelId}`, - }) - 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 pg.none( - `DELETE - FROM push_subscriptions - WHERE endpoint = $1 - AND user_id = $2`, - [subscription.endpoint, otherUser.id] - ); - } else { - console.error('Push failed', err); - } - } + const payload = { + title: `${creator.name}`, + body: textContent, + url: `/messages/${channelId}`, } + await sendWebNotifications(pg, receiverId, JSON.stringify(payload)) + await sendMobileNotifications(pg, receiverId, payload) const startOfDay = dayjs() .tz('America/Los_Angeles') @@ -222,7 +200,7 @@ const notifyOtherUserInChannelIfInactive = async ( log('previous messages this day', previousMessagesThisDayBetweenTheseUsers) if (previousMessagesThisDayBetweenTheseUsers.count > 1) return - await createNewMessageNotification(creator, otherUser, channelId) + await createNewMessageNotification(creator, receiver, channelId) } const createNewMessageNotification = async ( @@ -237,9 +215,38 @@ const createNewMessageNotification = async ( } -export async function getSubscriptionsFromDB( +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, - pg: SupabaseDirectClient ) { try { const subscriptions = await pg.manyOrNone(` @@ -247,14 +254,87 @@ export async function getSubscriptionsFromDB( 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 []; + 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 sendMobileNotifications( + pg: SupabaseDirectClient, + userId: string, + payload: PushPayload, +) { + const subscriptions = await getMobileSubscriptionsFromDB(pg, userId) + for (const subscription of subscriptions) { + await sendPushToToken(subscription.token, payload) + } +} + +interface PushPayload { + title: string + body: string + data?: Record +} + +export async function sendPushToToken(token: string, payload: PushPayload) { + const message = { + token, + notification: { + title: payload.title, + body: payload.body, + }, + data: payload.data, // optional custom key-value pairs + } + + try { + console.log('Sending notification to:', token, payload) + const response = await fcm.send(message) + console.log('Push sent successfully:', response) + return response + } catch (err) { + console.error('Error sending push:', 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/save-subscription-mobile.ts b/backend/api/src/save-subscription-mobile.ts new file mode 100644 index 00000000..4edd0b8e --- /dev/null +++ b/backend/api/src/save-subscription-mobile.ts @@ -0,0 +1,32 @@ +import {APIError, APIHandler} from './helpers/endpoint' +import {createSupabaseDirectClient} from 'shared/supabase/init' + +export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => { + const {token} = body + + if (!token) { + throw new APIError(400, `Invalid subscription object`) + } + + const userId = auth?.uid + + try { + const pg = createSupabaseDirectClient() + // Check if a subscription already exists + const exists = await pg.oneOrNone( + 'select id from push_subscriptions_mobile where token = $1', + [token] + ); + + if (!exists) { + await pg.none(`insert into push_subscriptions_mobile(token, platform, user_id) values($1, $2, $3) `, + [token, 'android', userId] + ); + } + + return {success: true}; + } catch (err) { + console.error('Error saving subscription', err); + throw new APIError(500, `Failed to save subscription`) + } +} diff --git a/backend/supabase/push_subscriptions_mobile.sql b/backend/supabase/push_subscriptions_mobile.sql new file mode 100644 index 00000000..2c6fe093 --- /dev/null +++ b/backend/supabase/push_subscriptions_mobile.sql @@ -0,0 +1,18 @@ +create table push_subscriptions_mobile ( + id serial primary key, + user_id text not null, + token text not null unique, + platform text not null, -- 'android' or 'ios' + created_at timestamptz default now(), + constraint push_subscriptions_mobile_user_id_fkey foreign KEY (user_id) references users (id) on delete CASCADE +); + +-- Row Level Security +ALTER TABLE push_subscriptions_mobile ENABLE ROW LEVEL SECURITY; + +-- Indexes +CREATE INDEX IF not exists user_id_idx ON push_subscriptions_mobile (user_id); + +CREATE INDEX IF not exists platform_idx ON push_subscriptions_mobile (platform); + +CREATE INDEX IF not exists platform_user_id_idx ON push_subscriptions_mobile (platform, user_id); \ No newline at end of file diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index f55ae9fc..7918a912 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -699,6 +699,17 @@ export const API = (_apiTypeCheck = { summary: 'Save a push/browser subscription for the user', tag: 'Notifications', }, + 'save-subscription-mobile': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as any, + props: z.object({ + token: z.string(), + }), + summary: 'Save a mobile push subscription for the user', + tag: 'Notifications', + }, 'create-bookmarked-search': { method: 'POST', authed: true, diff --git a/package.json b/package.json index 8bcb60bb..b7a58405 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@capacitor/core": "7.4.4", + "@capacitor/push-notifications": "7.0.3", "@playwright/test": "^1.54.2", "colorette": "^2.0.20", "prismjs": "^1.30.0", diff --git a/web/lib/service/android-push.ts b/web/lib/service/android-push.ts new file mode 100644 index 00000000..833525f0 --- /dev/null +++ b/web/lib/service/android-push.ts @@ -0,0 +1,34 @@ +import {PushNotifications} from '@capacitor/push-notifications' +import {useEffect} from "react" +import {api} from "web/lib/api" +import {useUser} from "web/hooks/use-user" + +export default function AndroidPush() { + const user = useUser() // authenticated user + const isWeb = typeof window !== 'undefined' && 'serviceWorker' in navigator + useEffect(() => { + if (!user?.id || isWeb) return + console.log('AndroidPush', user) + + PushNotifications.requestPermissions().then(result => { + if (result.receive !== 'granted') return + PushNotifications.register() + }) + + PushNotifications.addListener('registration', async token => { + console.log('Device token:', token.value) + try { + const {data} = await api('save-subscription-mobile', { token: token.value }) + console.log('Mobile subscription saved:', data) + } catch (err) { + console.error('Failed saving android subscription', err) + } + }) + + PushNotifications.addListener('pushNotificationReceived', notif => { + console.log('Push received', notif) + }) + }, [user?.id, isWeb]) + + return null +} diff --git a/web/lib/service/notifications.ts b/web/lib/service/web-push.ts similarity index 81% rename from web/lib/service/notifications.ts rename to web/lib/service/web-push.ts index 375e094e..5d1d1268 100644 --- a/web/lib/service/notifications.ts +++ b/web/lib/service/web-push.ts @@ -5,13 +5,12 @@ import {useUser} from "web/hooks/use-user"; const vapidPublicKey = 'BF80q7LrDa4a5ksS2BZrX6PPvL__y0jCNvNqyUzvk8Y4ofTdrS0kRnKfGpClCQAHWmcPHIUmWq8jgQ4ROquSpJQ' -export default function PushSubscriber() { +export default function WebPush() { const user = useUser(); // authenticated user - + const isWeb = typeof window !== 'undefined' && 'serviceWorker' in navigator; useEffect(() => { - console.log('PushSubscriber', user, 'serviceWorker' in navigator) - if (!user) return; // only subscribe logged-in users - if (!('serviceWorker' in navigator)) return; + if (!user?.id || !isWeb) return; + console.log('WebPush', user) const registerPush = async () => { navigator.serviceWorker @@ -37,7 +36,7 @@ export default function PushSubscriber() { applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) - // Send subscription to server + // Send subscription to backend const {data} = await api('save-subscription', {subscription}) console.log('Subscription saved:', data) }) @@ -48,7 +47,7 @@ export default function PushSubscriber() { }; registerPush() - }, [user?.id]) + }, [user?.id, isWeb]) - return null; // component doesn't render anything + return null } diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index aa65e75e..33f7bc46 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -10,7 +10,8 @@ import '../styles/globals.css' import {Major_Mono_Display} from 'next/font/google' import clsx from 'clsx' import {initTracking} from 'web/lib/service/analytics' -import PushSubscriber from "web/lib/service/notifications"; +import WebPush from "web/lib/service/web-push"; +import AndroidPush from "web/lib/service/android-push"; // See https://nextjs.org/docs/basic-features/font-optimization#google-fonts // and if you add a font, you must add it to tailwind config as well for it to work. @@ -111,7 +112,8 @@ function MyApp({Component, pageProps}: AppProps) { )} > - + + {/* Workaround for https://github.com/tailwindlabs/headlessui/discussions/666, to allow font CSS variable */} diff --git a/yarn.lock b/yarn.lock index c2fbf7be..21be6d40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1146,6 +1146,11 @@ dependencies: tslib "^2.1.0" +"@capacitor/push-notifications@7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@capacitor/push-notifications/-/push-notifications-7.0.3.tgz#b053f5c8db8cc8ceddda8dd9a1c744a72113e202" + integrity sha512-4qt5dRVBkHzq202NEoj3JC+S+zQCrZ1FJh7sjkICy/i1iEos+VaoB3bie8eWDQ2LTARktB4+k2xkdpu8pcVo/g== + "@corex/deepmerge@^2.6.148": version "2.6.148" resolved "https://registry.yarnpkg.com/@corex/deepmerge/-/deepmerge-2.6.148.tgz#8fa825d53ffd1cbcafce1b6a830eefd3dcc09dd5"