Skip to content

Commit 66da398

Browse files
committed
add send sms function
1 parent c262403 commit 66da398

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { createMockContext } from '../../../tests/helpers/mock-context';
2+
3+
describe('send-sms handler', () => {
4+
let handler: any;
5+
let fetchMock: jest.Mock;
6+
7+
beforeEach(() => {
8+
jest.resetModules();
9+
fetchMock = jest.fn().mockResolvedValue({ ok: true });
10+
global.fetch = fetchMock;
11+
handler = require('../handler').default;
12+
});
13+
14+
afterEach(() => {
15+
jest.restoreAllMocks();
16+
});
17+
18+
it('sends sms_otp_code messages to the configured capture API', async () => {
19+
const result = await handler(
20+
{
21+
sms_type: 'sms_otp_code',
22+
phone: '+15555550101',
23+
code: '123456',
24+
},
25+
createMockContext({
26+
env: {
27+
SMS_CAPTURE_API_URL: 'http://localhost:8091',
28+
SMS_FROM: 'Constructive Test',
29+
},
30+
})
31+
);
32+
33+
expect(result).toEqual({ complete: true });
34+
expect(fetchMock).toHaveBeenCalledWith(
35+
'http://localhost:8091/messages',
36+
expect.objectContaining({
37+
method: 'POST',
38+
body: JSON.stringify({
39+
to: '+15555550101',
40+
from: 'Constructive Test',
41+
text: 'Your verification code is 123456',
42+
type: 'sms_otp_code',
43+
metadata: {
44+
userId: null,
45+
},
46+
}),
47+
})
48+
);
49+
});
50+
51+
it('sends mfa_verification_code messages with combined phone fields', async () => {
52+
await handler(
53+
{
54+
sms_type: 'mfa_verification_code',
55+
phone_cc: '+1',
56+
phone_number: '(555) 555-0102',
57+
code: '234567',
58+
user_id: 'user-1',
59+
},
60+
createMockContext()
61+
);
62+
63+
expect(fetchMock).toHaveBeenCalledWith(
64+
'http://localhost:8091/messages',
65+
expect.objectContaining({
66+
body: JSON.stringify({
67+
to: '+15555550102',
68+
from: 'Constructive',
69+
text: 'Your verification code is 234567',
70+
type: 'mfa_verification_code',
71+
metadata: {
72+
userId: 'user-1',
73+
},
74+
}),
75+
})
76+
);
77+
});
78+
79+
it('skips capture API calls in dry-run mode', async () => {
80+
const result = await handler(
81+
{
82+
sms_type: 'sms_otp_code',
83+
phone: '+15555550103',
84+
code: '345678',
85+
},
86+
createMockContext({
87+
env: {
88+
SMS_SEND_DRY_RUN: 'true',
89+
},
90+
})
91+
);
92+
93+
expect(result).toEqual({ complete: true, dryRun: true });
94+
expect(fetchMock).not.toHaveBeenCalled();
95+
});
96+
97+
it('throws for missing sms_type', async () => {
98+
await expect(
99+
handler({ phone: '+15555550104', code: '456789' }, createMockContext())
100+
).rejects.toThrow('Missing required field: sms_type');
101+
});
102+
103+
it('throws for unsupported sms_type', async () => {
104+
await expect(
105+
handler(
106+
{
107+
sms_type: 'sms_invite',
108+
phone: '+15555550104',
109+
code: '456789',
110+
},
111+
createMockContext()
112+
)
113+
).rejects.toThrow('Unsupported sms_type: sms_invite');
114+
});
115+
116+
it('throws for missing code', async () => {
117+
await expect(
118+
handler({ sms_type: 'sms_otp_code', phone: '+15555550104' }, createMockContext())
119+
).rejects.toThrow('Missing required field: code');
120+
});
121+
122+
it('throws for missing phone', async () => {
123+
await expect(
124+
handler({ sms_type: 'sms_otp_code', code: '456789' }, createMockContext())
125+
).rejects.toThrow('Missing required field: phone');
126+
});
127+
});

functions/send-sms/handler.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "send-sms",
3+
"version": "0.1.0",
4+
"type": "node-graphql",
5+
"port": 8092,
6+
"taskIdentifier": "sms:send_verification_code",
7+
"description": "Sends SMS verification codes for auth flows"
8+
}

functions/send-sms/handler.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { FunctionHandler } from '@constructive-io/fn-runtime';
2+
3+
type SmsType = 'sms_otp_code' | 'mfa_verification_code';
4+
5+
type SendSmsParams = {
6+
sms_type?: SmsType | string;
7+
phone?: string;
8+
phone_cc?: string;
9+
phone_number?: string;
10+
code?: string;
11+
user_id?: string;
12+
};
13+
14+
type SendSmsContext = {
15+
env: Record<string, string | undefined>;
16+
log: {
17+
info: (...args: any[]) => void;
18+
};
19+
};
20+
21+
type SmsCapturePayload = {
22+
to: string;
23+
from: string;
24+
text: string;
25+
type: SmsType;
26+
metadata: {
27+
userId: string | null;
28+
};
29+
};
30+
31+
const DEFAULT_CAPTURE_API_URL = 'http://localhost:8091';
32+
const DEFAULT_SMS_FROM = 'Constructive';
33+
34+
function parseBoolean(value: string | undefined): boolean | undefined {
35+
if (value === undefined) return undefined;
36+
37+
switch (value.trim().toLowerCase()) {
38+
case '1':
39+
case 'true':
40+
case 'yes':
41+
case 'on':
42+
return true;
43+
case '0':
44+
case 'false':
45+
case 'no':
46+
case 'off':
47+
return false;
48+
default:
49+
return undefined;
50+
}
51+
}
52+
53+
function normalizePhone(params: SendSmsParams): string | null {
54+
if (params.phone) return params.phone;
55+
if (!params.phone_number) return null;
56+
57+
return `${params.phone_cc ?? ''}${params.phone_number}`.replace(/[\s()-]/g, '');
58+
}
59+
60+
function buildCapturePayload(params: SendSmsParams, env: SendSmsContext['env']): SmsCapturePayload {
61+
if (!params.sms_type) {
62+
throw new Error('Missing required field: sms_type');
63+
}
64+
65+
if (params.sms_type !== 'sms_otp_code' && params.sms_type !== 'mfa_verification_code') {
66+
throw new Error(`Unsupported sms_type: ${params.sms_type}`);
67+
}
68+
69+
if (!params.code) {
70+
throw new Error('Missing required field: code');
71+
}
72+
73+
const phone = normalizePhone(params);
74+
if (!phone) {
75+
throw new Error('Missing required field: phone');
76+
}
77+
78+
return {
79+
to: phone,
80+
from: env.SMS_FROM || DEFAULT_SMS_FROM,
81+
text: `Your verification code is ${params.code}`,
82+
type: params.sms_type,
83+
metadata: {
84+
userId: params.user_id ?? null,
85+
},
86+
};
87+
}
88+
89+
async function sendSms(params: SendSmsParams, context: SendSmsContext) {
90+
const payload = buildCapturePayload(params, context.env);
91+
const isDryRun = parseBoolean(context.env.SMS_SEND_DRY_RUN) ?? false;
92+
93+
context.log.info('[send-sms] Processing request', {
94+
sms_type: params.sms_type,
95+
to: payload.to,
96+
dryRun: isDryRun,
97+
});
98+
99+
if (isDryRun) {
100+
return {
101+
complete: true,
102+
dryRun: true,
103+
};
104+
}
105+
106+
const captureApiUrl = context.env.SMS_CAPTURE_API_URL || DEFAULT_CAPTURE_API_URL;
107+
const response = await fetch(`${captureApiUrl}/messages`, {
108+
method: 'POST',
109+
headers: {
110+
'content-type': 'application/json',
111+
},
112+
body: JSON.stringify(payload),
113+
});
114+
115+
if (!response.ok) {
116+
throw new Error(`SMS capture API error: ${response.status}`);
117+
}
118+
119+
return {
120+
complete: true,
121+
};
122+
}
123+
124+
const handler: FunctionHandler<SendSmsParams> = async (params, context) => {
125+
return sendSms(params, {
126+
env: context.env,
127+
log: context.log,
128+
});
129+
};
130+
131+
export default handler;

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)