Skip to content

Commit 6c864c3

Browse files
committed
Implement subscriptions for mobile notifications
1 parent f00acf6 commit 6c864c3

File tree

13 files changed

+303
-71
lines changed

13 files changed

+303
-71
lines changed

backend/api/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,27 @@
4949
"gcp-metadata": "6.1.0",
5050
"jsonwebtoken": "9.0.0",
5151
"lodash": "4.17.21",
52+
"openapi-types": "12.1.3",
5253
"pg-promise": "11.4.1",
5354
"posthog-node": "4.11.0",
55+
"react": "18.2.0",
56+
"react-dom": "18.2.0",
5457
"resend": "4.1.2",
5558
"string-similarity": "4.0.4",
5659
"swagger-jsdoc": "6.2.8",
5760
"swagger-ui-express": "5.0.1",
5861
"tsconfig-paths": "4.2.0",
5962
"twitter-api-v2": "1.15.0",
63+
"web-push": "3.6.7",
6064
"ws": "8.17.1",
61-
"react": "18.2.0",
62-
"react-dom": "18.2.0",
63-
"openapi-types": "12.1.3",
6465
"zod": "3.22.3"
6566
},
6667
"devDependencies": {
6768
"@types/cors": "2.8.17",
6869
"@types/react": "18.3.5",
6970
"@types/react-dom": "18.3.0",
7071
"@types/swagger-ui-express": "4.1.8",
72+
"@types/web-push": "3.6.4",
7173
"@types/ws": "8.5.10"
7274
}
7375
}

backend/api/src/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {API, type APIPath} from 'common/api/schema'
22
import {APIError, pathWithPrefix} from 'common/api/utils'
3-
import cors, {CorsOptions} from 'cors'
3+
import cors from 'cors'
44
import * as crypto from 'crypto'
55
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
66
import {hrtime} from 'node:process'
@@ -59,6 +59,7 @@ import {getMessagesCount} from "api/get-messages-count";
5959
import {createVote} from "api/create-vote";
6060
import {vote} from "api/vote";
6161
import {contact} from "api/contact";
62+
import {saveSubscription} from "api/save-subscription";
6263

6364
// const corsOptions: CorsOptions = {
6465
// origin: ['*'], // Only allow requests from this domain
@@ -183,6 +184,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
183184
'set-channel-seen-time': setChannelLastSeenTime,
184185
'get-messages-count': getMessagesCount,
185186
'set-last-online-time': setLastOnlineTime,
187+
'save-subscription': saveSubscription,
186188
}
187189

188190
Object.entries(handlers).forEach(([path, handler]) => {

backend/api/src/junk-drawer/private-messages.ts

Lines changed: 96 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
import { Json } from 'common/supabase/schema'
2-
import { SupabaseDirectClient } from 'shared/supabase/init'
3-
import { ChatVisibility } from 'common/chat-message'
4-
import { User } from 'common/user'
5-
import { first } from 'lodash'
6-
import { log } from 'shared/monitoring/log'
7-
import { getPrivateUser, getUser } from 'shared/utils'
8-
import { type JSONContent } from '@tiptap/core'
9-
import { APIError } from 'common/api/utils'
10-
import { broadcast } from 'shared/websockets/server'
11-
import { track } from 'shared/analytics'
12-
import { sendNewMessageEmail } from 'email/functions/helpers'
1+
import {Json} from 'common/supabase/schema'
2+
import {SupabaseDirectClient} from 'shared/supabase/init'
3+
import {ChatVisibility} from 'common/chat-message'
4+
import {User} from 'common/user'
5+
import {first} from 'lodash'
6+
import {log} from 'shared/monitoring/log'
7+
import {getPrivateUser, getUser} from 'shared/utils'
8+
import {type JSONContent} from '@tiptap/core'
9+
import {APIError} from 'common/api/utils'
10+
import {broadcast} from 'shared/websockets/server'
11+
import {track} from 'shared/analytics'
12+
import {sendNewMessageEmail} from 'email/functions/helpers'
1313
import dayjs from 'dayjs'
1414
import utc from 'dayjs/plugin/utc'
1515
import timezone from 'dayjs/plugin/timezone'
16+
import webPush from 'web-push';
1617

1718
dayjs.extend(utc)
1819
dayjs.extend(timezone)
@@ -22,7 +23,7 @@ export const leaveChatContent = (userName: string) => ({
2223
content: [
2324
{
2425
type: 'paragraph',
25-
content: [{ text: `${userName} left the chat`, type: 'text' }],
26+
content: [{text: `${userName} left the chat`, type: 'text'}],
2627
},
2728
],
2829
})
@@ -32,7 +33,7 @@ export const joinChatContent = (userName: string) => {
3233
content: [
3334
{
3435
type: 'paragraph',
35-
content: [{ text: `${userName} joined the chat!`, type: 'text' }],
36+
content: [{text: `${userName} joined the chat!`, type: 'text'}],
3637
},
3738
],
3839
}
@@ -47,11 +48,14 @@ export const insertPrivateMessage = async (
4748
) => {
4849
const lastMessage = await pg.one(
4950
`insert into private_user_messages (content, channel_id, user_id, visibility)
50-
values ($1, $2, $3, $4) returning created_time`,
51+
values ($1, $2, $3, $4)
52+
returning created_time`,
5153
[content, channelId, userId, visibility]
5254
)
5355
await pg.none(
54-
`update private_user_message_channels set last_updated_time = $1 where id = $2`,
56+
`update private_user_message_channels
57+
set last_updated_time = $1
58+
where id = $2`,
5559
[lastMessage.created_time, channelId]
5660
)
5761
}
@@ -65,16 +69,17 @@ export const addUsersToPrivateMessageChannel = async (
6569
userIds.map((id) =>
6670
pg.none(
6771
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
68-
values
69-
($1, $2, 'member', 'proposed')
70-
on conflict do nothing
71-
`,
72+
values ($1, $2, 'member', 'proposed')
73+
on conflict do nothing
74+
`,
7275
[channelId, id]
7376
)
7477
)
7578
)
7679
await pg.none(
77-
`update private_user_message_channels set last_updated_time = now() where id = $1`,
80+
`update private_user_message_channels
81+
set last_updated_time = now()
82+
where id = $1`,
7883
[channelId]
7984
)
8085
}
@@ -90,9 +95,9 @@ export const createPrivateUserMessageMain = async (
9095
// Normally, users can only submit messages to channels that they are members of
9196
const authorized = await pg.oneOrNone(
9297
`select 1
93-
from private_user_message_channel_members
94-
where channel_id = $1
95-
and user_id = $2`,
98+
from private_user_message_channel_members
99+
where channel_id = $1
100+
and user_id = $2`,
96101
[channelId, creator.id]
97102
)
98103
if (!authorized)
@@ -108,10 +113,12 @@ export const createPrivateUserMessageMain = async (
108113
}
109114

110115
const otherUserIds = await pg.map<string>(
111-
`select user_id from private_user_message_channel_members
112-
where channel_id = $1 and user_id != $2
113-
and status != 'left'
114-
`,
116+
`select user_id
117+
from private_user_message_channel_members
118+
where channel_id = $1
119+
and user_id != $2
120+
and status != 'left'
121+
`,
115122
[channelId, creator.id],
116123
(r) => r.user_id
117124
)
@@ -133,10 +140,12 @@ const notifyOtherUserInChannelIfInactive = async (
133140
pg: SupabaseDirectClient
134141
) => {
135142
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
136-
`select user_id from private_user_message_channel_members
137-
where channel_id = $1 and user_id != $2
138-
and status != 'left'
139-
`,
143+
`select user_id
144+
from private_user_message_channel_members
145+
where channel_id = $1
146+
and user_id != $2
147+
and status != 'left'
148+
`,
140149
[channelId, creator.id]
141150
)
142151
// We're only sending notifs for 1:1 channels
@@ -150,11 +159,12 @@ const notifyOtherUserInChannelIfInactive = async (
150159
.startOf('day')
151160
.toISOString()
152161
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
153-
`select count(*) from private_user_messages
154-
where channel_id = $1
155-
and user_id = $2
156-
and created_time > $3
157-
`,
162+
`select count(*)
163+
from private_user_messages
164+
where channel_id = $1
165+
and user_id = $2
166+
and created_time > $3
167+
`,
158168
[channelId, creator.id, startOfDay]
159169
)
160170
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
@@ -166,16 +176,63 @@ const notifyOtherUserInChannelIfInactive = async (
166176
console.debug('otherUser:', otherUser)
167177
if (!otherUser) return
168178

169-
await createNewMessageNotification(creator, otherUser, channelId)
179+
await createNewMessageNotification(creator, otherUser, channelId, pg)
170180
}
171181

172182
const createNewMessageNotification = async (
173183
fromUser: User,
174184
toUser: User,
175-
channelId: number
185+
channelId: number,
186+
pg: SupabaseDirectClient
176187
) => {
177188
const privateUser = await getPrivateUser(toUser.id)
178189
console.debug('privateUser:', privateUser)
179190
if (!privateUser) return
191+
192+
webPush.setVapidDetails(
193+
194+
process.env.VAPID_PUBLIC_KEY!,
195+
process.env.VAPID_PRIVATE_KEY!
196+
);
197+
198+
// Retrieve subscription from your database
199+
const subscriptions = await getSubscriptionsFromDB(toUser.id, pg);
200+
201+
for (const subscription of subscriptions) {
202+
try {
203+
console.log('Sending notification to:', subscription.endpoint);
204+
await webPush.sendNotification(subscription, JSON.stringify({
205+
title: `Message from ${fromUser.name}`,
206+
body: 'You have a new message!',
207+
url: `/messages/${channelId}`,
208+
}));
209+
} catch (err) {
210+
console.error('Failed to send notification', err);
211+
// optionally remove invalid subscription from DB
212+
}
213+
}
214+
180215
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
181216
}
217+
218+
219+
export async function getSubscriptionsFromDB(
220+
userId: string,
221+
pg: SupabaseDirectClient
222+
) {
223+
try {
224+
const subscriptions = await pg.manyOrNone(`
225+
select endpoint, keys from push_subscriptions
226+
where user_id = $1
227+
`, [userId]
228+
);
229+
230+
return subscriptions.map(sub => ({
231+
endpoint: sub.endpoint,
232+
keys: sub.keys,
233+
}));
234+
} catch (err) {
235+
console.error('Error fetching subscriptions', err);
236+
return [];
237+
}
238+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {APIError, APIHandler} from './helpers/endpoint'
2+
import {createSupabaseDirectClient} from 'shared/supabase/init'
3+
4+
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
5+
const {subscription} = body
6+
7+
if (!subscription?.endpoint || !subscription?.keys) {
8+
throw new APIError(400, `Invalid subscription object`)
9+
}
10+
11+
const userId = auth?.uid
12+
13+
try {
14+
const pg = createSupabaseDirectClient()
15+
// Check if a subscription already exists
16+
const exists = await pg.oneOrNone(
17+
'select id from push_subscriptions where endpoint = $1',
18+
[subscription.endpoint]
19+
);
20+
21+
if (exists) {
22+
// Already exists, optionally update keys and userId
23+
await pg.none(
24+
'update push_subscriptions set keys = $1, user_id = $2 where id = $3',
25+
[subscription.keys, userId, exists.id]
26+
);
27+
} else {
28+
await pg.none(
29+
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
30+
on conflict(endpoint) do update set keys = excluded.keys
31+
`,
32+
[subscription.endpoint, subscription.keys, userId]
33+
);
34+
}
35+
36+
return {success: true};
37+
} catch (err) {
38+
console.error('Error saving subscription', err);
39+
throw new APIError(500, `Failed to save subscription`)
40+
}
41+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
create table push_subscriptions (
2+
id serial primary key,
3+
user_id TEXT references users(id), -- optional if per-user
4+
endpoint text not null unique,
5+
keys jsonb not null,
6+
created_at timestamptz default now()
7+
);
8+
9+
-- Row Level Security
10+
ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;

common/src/api/schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,15 @@ export const API = (_apiTypeCheck = {
572572
props: z.object({}),
573573
returns: {} as { count: number },
574574
},
575+
'save-subscription': {
576+
method: 'POST',
577+
authed: true,
578+
rateLimited: true,
579+
returns: {} as any,
580+
props: z.object({
581+
subscription: z.record(z.any())
582+
}),
583+
},
575584
} as const)
576585

577586
export type APIPath = keyof typeof API

common/src/util/parse.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
getText,
33
getSchema,
44
getTextSerializersFromSchema,
5-
Node,
65
JSONContent,
76
} from '@tiptap/core'
87
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
@@ -77,3 +76,10 @@ export function richTextToString(text?: JSONContent) {
7776
export function parseJsonContentToText(content: JSONContent | string) {
7877
return typeof content === 'string' ? content : richTextToString(content)
7978
}
79+
80+
export function urlBase64ToUint8Array(base64String: string) {
81+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
82+
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
83+
const rawData = window.atob(base64);
84+
return new Uint8Array([...rawData].map(c => c.charCodeAt(0)));
85+
}

0 commit comments

Comments
 (0)