Skip to content

Commit ae72dbb

Browse files
committed
feat(push-notifications): switch delivery from FCM to Expo Push Service
Firebase's sendEachForMulticast cannot deliver to Expo push tokens, and the mobile app is standardizing on Expo for stage one. No production device tokens exist yet, making this a clean replacement rather than a migration. Also fixes an authz bug where device-token unregistration was scoped by token alone, letting any authenticated user unregister another user's token. - Replace firebase-admin with expo-server-sdk; delete notification/lib/firebase.ts - Chunk pushes at Expo's 100-per-request limit and correlate delivery tickets to tokens by index within each chunk (previous FCM code had no chunking at all) - Prune invalid-format and DeviceNotRegistered tokens after send - Enqueue message preview text/contentType/attachmentCount at the producer, since Message's hypertable key makes a worker-side lookup impractical from the job payload alone - Resolve notification title/body via a pure, unit-tested builder with a small worker-local string table (next-intl isn't in the worker's dependency graph) - Scope deviceTokenService.deleteByToken by userId
1 parent 93797f1 commit ae72dbb

15 files changed

Lines changed: 565 additions & 766 deletions

File tree

.env.example

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ REDIS_URL=redis://localhost:6379
7171
# INTEGRATION_WORKER_CONCURRENCY=10
7272
# NOTIFICATION_WORKER_CONCURRENCY=10
7373

74-
# Firebase Cloud Messaging service account JSON (single-line string). Optional —
75-
# push notifications are disabled and the worker logs once when unset.
76-
# FIREBASE_SERVICE_ACCOUNT={"type":"service_account",...}
74+
# Expo Push Service. Expo needs no credential to send, so EXPO_PUSH_ENABLED is
75+
# the explicit kill switch (defaults to true). EXPO_ACCESS_TOKEN is only
76+
# needed if Expo's "enhanced push security" is enabled on the project.
77+
# EXPO_PUSH_ENABLED=true
78+
# EXPO_ACCESS_TOKEN=
7779

7880
# ─────────────────────────────────────────────
7981
# JavaScript Executor

apps/builder/src/features/device-tokens/api/authenticated.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ export const deviceTokensAuthenticatedAPI = {
3737
})
3838
.input(unregisterDeviceTokenRequest)
3939
.output(successResponse)
40-
.handler(async ({ input }) => {
41-
await deviceTokenService.deleteByToken({ token: input.token })
40+
.handler(async ({ input, context }) => {
41+
await deviceTokenService.deleteByToken({
42+
userId: context.user.id,
43+
token: input.token,
44+
})
4245
return { success: true as const }
4346
}),
4447
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// @vitest-environment node
2+
import { describe, expect, test } from "vitest"
3+
import { buildNotificationContent } from "../../src/notification/lib/build-notification-content"
4+
5+
const incomingMessageJob = (data: Record<string, unknown> = {}) =>
6+
({
7+
type: "notifyIncomingMessage",
8+
data: {
9+
workspaceId: "ws-1",
10+
conversationId: "conv-1",
11+
messageId: "msg-1",
12+
...data,
13+
},
14+
}) as never
15+
16+
const assignedJob = () =>
17+
({
18+
type: "notifyConversationAssigned",
19+
data: {
20+
workspaceId: "ws-1",
21+
conversationId: "conv-1",
22+
assignedUserId: "user-1",
23+
},
24+
}) as never
25+
26+
describe("buildNotificationContent", () => {
27+
test("uses contact full name as title", () => {
28+
const result = buildNotificationContent({
29+
job: incomingMessageJob({ messageText: "Hey there" }),
30+
contactFullName: "Jane Doe",
31+
workspaceLanguage: "en",
32+
})
33+
expect(result.title).toBe("Jane Doe")
34+
expect(result.body).toBe("Hey there")
35+
})
36+
37+
test("falls back to generic title when contact has no name", () => {
38+
const result = buildNotificationContent({
39+
job: incomingMessageJob({ messageText: "Hey there" }),
40+
contactFullName: null,
41+
workspaceLanguage: "en",
42+
})
43+
expect(result.title).toBe("New message")
44+
})
45+
46+
test("uses location placeholder body", () => {
47+
const result = buildNotificationContent({
48+
job: incomingMessageJob({ contentType: "location" }),
49+
contactFullName: "Jane",
50+
workspaceLanguage: "en",
51+
})
52+
expect(result.body).toBe("Shared a location")
53+
})
54+
55+
test("uses refLink placeholder body", () => {
56+
const result = buildNotificationContent({
57+
job: incomingMessageJob({ contentType: "refLink" }),
58+
contactFullName: "Jane",
59+
workspaceLanguage: "en",
60+
})
61+
expect(result.body).toBe("Sent a link")
62+
})
63+
64+
test("uses singular attachment copy", () => {
65+
const result = buildNotificationContent({
66+
job: incomingMessageJob({ attachmentCount: 1 }),
67+
contactFullName: "Jane",
68+
workspaceLanguage: "en",
69+
})
70+
expect(result.body).toBe("Sent an attachment")
71+
})
72+
73+
test("uses plural attachment-count copy", () => {
74+
const result = buildNotificationContent({
75+
job: incomingMessageJob({ attachmentCount: 2 }),
76+
contactFullName: "Jane",
77+
workspaceLanguage: "en",
78+
})
79+
expect(result.body).toBe("Sent 2 attachments")
80+
})
81+
82+
test("assigned-conversation copy is templated", () => {
83+
const result = buildNotificationContent({
84+
job: assignedJob(),
85+
contactFullName: "Jane",
86+
workspaceLanguage: "en",
87+
})
88+
expect(result.title).toBe("Jane")
89+
expect(result.body).toBe("You were assigned a conversation")
90+
})
91+
92+
test("falls back to en for an unknown workspace language", () => {
93+
const result = buildNotificationContent({
94+
job: incomingMessageJob({ contentType: "location" }),
95+
contactFullName: "Jane",
96+
workspaceLanguage: "fr",
97+
})
98+
expect(result.body).toBe("Shared a location")
99+
})
100+
101+
test("falls back to en when workspace language is undefined", () => {
102+
const result = buildNotificationContent({
103+
job: incomingMessageJob({ contentType: "location" }),
104+
contactFullName: "Jane",
105+
workspaceLanguage: undefined,
106+
})
107+
expect(result.body).toBe("Shared a location")
108+
})
109+
})
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// @vitest-environment node
2+
import { beforeEach, describe, expect, test, vi } from "vitest"
3+
4+
const chunkPushNotifications = vi.fn(
5+
(messages: { to: string }[]): { to: string }[][] => {
6+
const chunks: { to: string }[][] = []
7+
for (let i = 0; i < messages.length; i += 2) {
8+
chunks.push(messages.slice(i, i + 2))
9+
}
10+
return chunks
11+
},
12+
)
13+
const sendPushNotificationsAsync = vi.fn()
14+
const isExpoPushToken = vi.fn(
15+
(token: string) => typeof token === "string" && token.startsWith("Expo["),
16+
)
17+
18+
vi.mock("expo-server-sdk", () => ({
19+
Expo: class {
20+
static isExpoPushToken = isExpoPushToken
21+
chunkPushNotifications = chunkPushNotifications
22+
sendPushNotificationsAsync = sendPushNotificationsAsync
23+
},
24+
}))
25+
26+
vi.mock("../../src/env", () => ({
27+
env: { EXPO_PUSH_ENABLED: true, EXPO_ACCESS_TOKEN: undefined },
28+
}))
29+
30+
const findByOrFail = vi.fn().mockResolvedValue({
31+
id: "conv-1",
32+
assignedUserId: "user-1",
33+
contactId: "contact-1",
34+
})
35+
const listUserIdsByWorkspaceId = vi.fn().mockResolvedValue(["user-1"])
36+
const findByUserIds = vi.fn()
37+
const deleteByTokens = vi.fn().mockResolvedValue(undefined)
38+
const contactFindById = vi.fn().mockResolvedValue({ fullName: "Jane Doe" })
39+
const workspaceFindById = vi.fn().mockResolvedValue({ language: "en" })
40+
41+
vi.mock("@chatbotx.io/business", () => ({
42+
conversationService: { findByOrFail },
43+
workspaceMemberService: { listUserIdsByWorkspaceId },
44+
deviceTokenService: { findByUserIds, deleteByTokens },
45+
contactService: { findById: contactFindById },
46+
workspaceService: { findById: workspaceFindById },
47+
}))
48+
49+
vi.mock("../../src/lib/logger", () => ({
50+
logger: { warn: vi.fn(), info: vi.fn() },
51+
}))
52+
53+
const { sendPushForNotificationJob } = await import(
54+
"../../src/notification/handlers/send-push"
55+
)
56+
57+
const baseJob = () =>
58+
({
59+
type: "notifyIncomingMessage",
60+
data: {
61+
workspaceId: "ws-1",
62+
conversationId: "conv-1",
63+
messageId: "msg-1",
64+
messageText: "Hello",
65+
},
66+
}) as never
67+
68+
beforeEach(() => {
69+
vi.clearAllMocks()
70+
isExpoPushToken.mockImplementation(
71+
(token: string) => typeof token === "string" && token.startsWith("Expo["),
72+
)
73+
chunkPushNotifications.mockImplementation(
74+
(messages: { to: string }[]): { to: string }[][] => {
75+
const chunks: { to: string }[][] = []
76+
for (let i = 0; i < messages.length; i += 2) {
77+
chunks.push(messages.slice(i, i + 2))
78+
}
79+
return chunks
80+
},
81+
)
82+
findByOrFail.mockResolvedValue({
83+
id: "conv-1",
84+
assignedUserId: "user-1",
85+
contactId: "contact-1",
86+
})
87+
contactFindById.mockResolvedValue({ fullName: "Jane Doe" })
88+
workspaceFindById.mockResolvedValue({ language: "en" })
89+
deleteByTokens.mockResolvedValue(undefined)
90+
})
91+
92+
describe("sendPushForNotificationJob", () => {
93+
test("sends a message with non-empty title and body", async () => {
94+
findByUserIds.mockResolvedValue([{ token: "Expo[token-1]" }])
95+
sendPushNotificationsAsync.mockResolvedValue([{ status: "ok", id: "r1" }])
96+
97+
await sendPushForNotificationJob(baseJob())
98+
99+
expect(sendPushNotificationsAsync).toHaveBeenCalledOnce()
100+
const sentMessages = sendPushNotificationsAsync.mock.calls[0][0]
101+
expect(sentMessages[0].title).toBe("Jane Doe")
102+
expect(sentMessages[0].body).toBe("Hello")
103+
})
104+
105+
test("prunes a token whose ticket reports DeviceNotRegistered", async () => {
106+
findByUserIds.mockResolvedValue([{ token: "Expo[stale-token]" }])
107+
sendPushNotificationsAsync.mockResolvedValue([
108+
{
109+
status: "error",
110+
message: "not registered",
111+
details: { error: "DeviceNotRegistered" },
112+
},
113+
])
114+
115+
await sendPushForNotificationJob(baseJob())
116+
117+
expect(deleteByTokens).toHaveBeenCalledWith({
118+
tokens: ["Expo[stale-token]"],
119+
})
120+
})
121+
122+
test("filters out and prunes an invalid-format token without sending it", async () => {
123+
findByUserIds.mockResolvedValue([{ token: "placeholder-legacy-token" }])
124+
125+
await sendPushForNotificationJob(baseJob())
126+
127+
expect(deleteByTokens).toHaveBeenCalledWith({
128+
tokens: ["placeholder-legacy-token"],
129+
})
130+
expect(sendPushNotificationsAsync).not.toHaveBeenCalled()
131+
})
132+
133+
test("correlates tickets to tokens by index within each chunk", async () => {
134+
findByUserIds.mockResolvedValue([
135+
{ token: "Expo[token-1]" },
136+
{ token: "Expo[token-2]" },
137+
{ token: "Expo[token-3]" },
138+
])
139+
sendPushNotificationsAsync
140+
.mockResolvedValueOnce([
141+
{ status: "ok", id: "r1" },
142+
{
143+
status: "error",
144+
message: "not registered",
145+
details: { error: "DeviceNotRegistered" },
146+
},
147+
])
148+
.mockResolvedValueOnce([
149+
{
150+
status: "error",
151+
message: "not registered",
152+
details: { error: "DeviceNotRegistered" },
153+
},
154+
])
155+
156+
await sendPushForNotificationJob(baseJob())
157+
158+
expect(sendPushNotificationsAsync).toHaveBeenCalledTimes(2)
159+
expect(deleteByTokens).toHaveBeenCalledWith({
160+
tokens: ["Expo[token-2]", "Expo[token-3]"],
161+
})
162+
})
163+
164+
test("a throwing chunk does not prevent the other chunk from sending", async () => {
165+
findByUserIds.mockResolvedValue([
166+
{ token: "Expo[token-1]" },
167+
{ token: "Expo[token-2]" },
168+
{ token: "Expo[token-3]" },
169+
])
170+
sendPushNotificationsAsync
171+
.mockRejectedValueOnce(new Error("network error"))
172+
.mockResolvedValueOnce([{ status: "ok", id: "r1" }])
173+
174+
await expect(sendPushForNotificationJob(baseJob())).resolves.toBeUndefined()
175+
expect(sendPushNotificationsAsync).toHaveBeenCalledTimes(2)
176+
})
177+
178+
test("returns early when EXPO_PUSH_ENABLED is false", async () => {
179+
vi.resetModules()
180+
vi.doMock("../../src/env", () => ({
181+
env: { EXPO_PUSH_ENABLED: false, EXPO_ACCESS_TOKEN: undefined },
182+
}))
183+
const { sendPushForNotificationJob: sendWithDisabled } = await import(
184+
"../../src/notification/handlers/send-push"
185+
)
186+
187+
await sendWithDisabled(baseJob())
188+
189+
expect(findByUserIds).not.toHaveBeenCalled()
190+
})
191+
192+
test("returns early when there are no device tokens", async () => {
193+
findByUserIds.mockResolvedValue([])
194+
195+
await sendPushForNotificationJob(baseJob())
196+
197+
expect(sendPushNotificationsAsync).not.toHaveBeenCalled()
198+
})
199+
})

apps/worker/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
"date-fns": "^4.1.0",
8585
"date-fns-tz": "^3.2.0",
8686
"dot-prop": "^10.1.0",
87-
"firebase-admin": "^14.3.0",
87+
"expo-server-sdk": "^7.1.0",
8888
"html-to-text": "^9.0.5",
8989
"image-size": "^2.0.2",
9090
"ioredis": "5.10.1",

apps/worker/src/env.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ export const env = createEnv({
3737
.min(1)
3838
.max(200)
3939
.default(10),
40-
// Firebase Cloud Messaging service account JSON, serialized to a single
41-
// env string. Optional — when unset, push notifications are disabled and
42-
// the worker logs once instead of throwing.
43-
FIREBASE_SERVICE_ACCOUNT: z.string().optional(),
40+
// Expo push access token. Only needed if Expo's "enhanced push security"
41+
// is enabled on the project; unauthenticated requests work otherwise.
42+
EXPO_ACCESS_TOKEN: z.string().optional(),
43+
// Kill switch — Expo needs no credential to send, so unlike FCM there is
44+
// no natural "unset = disabled" signal. Operators flip this explicitly.
45+
EXPO_PUSH_ENABLED: z.coerce.boolean().default(true),
4446
},
4547
runtimeEnv: process.env,
4648
skipValidation: process.env.SKIP_ENV_CHECK === "true",

apps/worker/src/integration/handlers/received-message.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,9 @@ const saveAndBroadcastMessage = async (props: {
672672
workspaceId: inbox.workspaceId,
673673
conversationId: conversation.id,
674674
messageId: newMessage.id,
675+
messageText: newMessage.text?.slice(0, 140),
676+
contentType: newMessage.contentType,
677+
attachmentCount: newMessage.attachments.length,
675678
},
676679
},
677680
{ jobId: `notify-incoming-${newMessage.id}` },

0 commit comments

Comments
 (0)