Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/services/notification/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@ class NotificationServiceImpl implements NotificationService {
}

private async sendWhatsApp(to: string, body: string): Promise<boolean> {
// Baileys bridge delivery (TEST/dev): when WA_BRIDGE_URL + WA_BRIDGE_SECRET
// are configured, WhatsApp messages go out through the wa-bridge's authed
// POST /send instead of Twilio (no Twilio WhatsApp sender is provisioned
// in any env today). Unset both to use the Twilio template path.
if (process.env.WA_BRIDGE_URL && process.env.WA_BRIDGE_SECRET) {
return this.sendWhatsAppViaBridge(
process.env.WA_BRIDGE_URL,
process.env.WA_BRIDGE_SECRET,
to,
body,
)
}

if (!this.twilioClient) {
baseLogger.error("Twilio client not configured")
return false
Expand Down Expand Up @@ -250,6 +263,43 @@ class NotificationServiceImpl implements NotificationService {
return false
}
}

// Deliver a plain-text WhatsApp message through the Baileys wa-bridge
// (flash-support-infra/services/wa-bridge, authed POST /send). Message
// content is never logged — invite links carry one-time tokens.
private async sendWhatsAppViaBridge(
url: string,
secret: string,
to: string,
body: string,
): Promise<boolean> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15_000)
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Send-Token": secret },
body: JSON.stringify({ to, text: body }),
signal: controller.signal,
})
const payload = (await resp.json().catch(() => ({}))) as { ok?: boolean }
const ok = resp.ok && payload.ok === true
if (ok) {
baseLogger.info(
{ to, bodyLength: body.length },
"WhatsApp message sent via wa-bridge",
)
} else {
baseLogger.error({ to, status: resp.status }, "wa-bridge send failed")
}
return ok
} catch (error) {
baseLogger.error({ error: String(error), to }, "wa-bridge send error")
return false
} finally {
clearTimeout(timeout)
}
}
}

export const notificationService = new NotificationServiceImpl()
21 changes: 13 additions & 8 deletions src/services/notifications/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,19 @@ export const sendInviteNotification = async ({
break

case InviteMethod.WHATSAPP:
// For WhatsApp templates (if using approved templates)
messageBody = JSON.stringify({
templateName: "flash_invite",
templateVariables: {
"1": senderName,
"2": token,
},
})
if (process.env.WA_BRIDGE_URL && process.env.WA_BRIDGE_SECRET) {
// Baileys wa-bridge delivery: plain text, no Meta template needed.
messageBody = `${senderName} invited you to Flash! Join using this link: ${inviteLink}`
} else {
// Twilio path: approved WhatsApp template.
messageBody = JSON.stringify({
templateName: "flash_invite",
templateVariables: {
"1": senderName,
"2": token,
},
})
}
break

case InviteMethod.SMS:
Expand Down
81 changes: 81 additions & 0 deletions test/flash/unit/services/notification/wa-bridge.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { notificationService, NotificationMethod } from "@services/notification"

// The Twilio client is intentionally unconfigured in this suite (no TWILIO_*
// env), so the non-bridge path deterministically fails — which lets us assert
// the bridge path is what succeeded.

describe("wa-bridge WhatsApp delivery", () => {
const realFetch = global.fetch
let fetchMock: jest.Mock

beforeEach(() => {
fetchMock = jest.fn()
global.fetch = fetchMock as unknown as typeof fetch
process.env.WA_BRIDGE_URL = "http://bridge.local:3850/send"
process.env.WA_BRIDGE_SECRET = "sekrit"
})

afterEach(() => {
global.fetch = realFetch
delete process.env.WA_BRIDGE_URL
delete process.env.WA_BRIDGE_SECRET
})

const okResponse = (body: unknown, status = 200) =>
({ ok: status >= 200 && status < 300, status, json: async () => body }) as Response

it("sends via the bridge with the auth header and {to, text} body", async () => {
fetchMock.mockResolvedValue(okResponse({ ok: true, id: "MSGID" }))

const result = await notificationService.sendNotification(
NotificationMethod.WHATSAPP,
"+18765550123",
"hello from flash",
)

expect(result).toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe("http://bridge.local:3850/send")
expect(init.method).toBe("POST")
expect(init.headers["X-Send-Token"]).toBe("sekrit")
expect(JSON.parse(init.body)).toEqual({
to: "+18765550123",
text: "hello from flash",
})
})

it("returns false when the bridge responds without ok:true", async () => {
fetchMock.mockResolvedValue(okResponse({ error: "unauthorized" }, 401))
const result = await notificationService.sendNotification(
NotificationMethod.WHATSAPP,
"+18765550123",
"hello",
)
expect(result).toBe(false)
})

it("returns false when the bridge call throws (network)", async () => {
fetchMock.mockRejectedValue(new Error("ECONNREFUSED"))
const result = await notificationService.sendNotification(
NotificationMethod.WHATSAPP,
"+18765550123",
"hello",
)
expect(result).toBe(false)
})

it("falls back to the Twilio path (and does not call the bridge) when unconfigured", async () => {
delete process.env.WA_BRIDGE_URL
delete process.env.WA_BRIDGE_SECRET
const result = await notificationService.sendNotification(
NotificationMethod.WHATSAPP,
"+18765550123",
"hello",
)
// No Twilio client in this suite -> the fallback path fails, proving the
// bridge really is the only reason the configured case succeeds.
expect(result).toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})
})
58 changes: 58 additions & 0 deletions test/flash/unit/services/notifications/invite-body.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { InviteMethod } from "@domain/invite"

const mockSendNotification = jest.fn()
jest.mock("@services/notification", () => ({
NotificationMethod: { EMAIL: "EMAIL", SMS: "SMS", WHATSAPP: "WHATSAPP" },
notificationService: {
sendNotification: (...a: unknown[]) => mockSendNotification(...a),
},
}))

import { sendInviteNotification } from "@services/notifications/invite"

const TOKEN = "a".repeat(40)

describe("sendInviteNotification WHATSAPP body selection", () => {
beforeEach(() => {
mockSendNotification.mockReset()
mockSendNotification.mockResolvedValue(true)
})

afterEach(() => {
delete process.env.WA_BRIDGE_URL
delete process.env.WA_BRIDGE_SECRET
})

it("uses plain text with the invite link when the wa-bridge is configured", async () => {
process.env.WA_BRIDGE_URL = "http://bridge.local:3850/send"
process.env.WA_BRIDGE_SECRET = "sekrit"

const ok = await sendInviteNotification({
method: InviteMethod.WHATSAPP,
contact: "+18765550123",
token: TOKEN,
senderName: "dreadlocks",
})

expect(ok).toBe(true)
const body = mockSendNotification.mock.calls[0][2] as string
expect(body).toContain("dreadlocks invited you to Flash!")
expect(body).toContain(`token=${TOKEN}`)
expect(() => JSON.parse(body)).toThrow() // plain text, not a template blob
})

it("uses the Twilio template blob when the bridge is not configured", async () => {
const ok = await sendInviteNotification({
method: InviteMethod.WHATSAPP,
contact: "+18765550123",
token: TOKEN,
senderName: "dreadlocks",
})

expect(ok).toBe(true)
const body = mockSendNotification.mock.calls[0][2] as string
const parsed = JSON.parse(body)
expect(parsed.templateName).toBe("flash_invite")
expect(parsed.templateVariables["2"]).toBe(TOKEN)
})
})
Loading