Skip to content

Commit e046586

Browse files
authored
Merge pull request #1383 from constructive-io/feat/send-sms-verification-code-flow
feat: add typed SMS configuration flow
2 parents 4754b13 + fab3be9 commit e046586

8 files changed

Lines changed: 223 additions & 5 deletions

File tree

graphql/env/__tests__/__snapshots__/merge.test.ts.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
120120
"strictAuth": false,
121121
"trustProxy": false,
122122
},
123+
"sms": {
124+
"devsms": {
125+
"baseUrl": "http://env-devsms:4000",
126+
},
127+
"dryRun": true,
128+
"provider": "devsms",
129+
"requestTimeoutMs": 9000,
130+
"senderId": "OverrideSender",
131+
},
123132
"smtp": {
124133
"debug": false,
125134
"logger": false,

graphql/env/__tests__/merge.test.ts

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getEnvOptions } from '../src/merge';
2+
import { getGraphQLEnvVars } from '../src/env';
23
import * as fs from 'fs';
34
import * as os from 'os';
45
import * as path from 'path';
@@ -37,7 +38,16 @@ describe('getEnvOptions', () => {
3738
enableServicesApi: false,
3839
isPublic: false,
3940
metaSchemas: ['config_meta']
40-
}
41+
},
42+
sms: {
43+
provider: 'devsms',
44+
senderId: 'ConfigSender',
45+
requestTimeoutMs: 3000,
46+
dryRun: false,
47+
devsms: {
48+
baseUrl: 'http://config-devsms:4000',
49+
},
50+
},
4151
});
4252

4353
const testEnv: NodeJS.ProcessEnv = {
@@ -52,7 +62,12 @@ describe('getEnvOptions', () => {
5262
API_META_SCHEMAS: 'env_meta1,env_meta2',
5363
API_ANON_ROLE: 'env_anon',
5464
API_ROLE_NAME: 'env_role',
55-
API_DEFAULT_DATABASE_ID: 'env_db'
65+
API_DEFAULT_DATABASE_ID: 'env_db',
66+
SMS_PROVIDER: 'devsms',
67+
SMS_SENDER_ID: 'EnvSender',
68+
SMS_REQUEST_TIMEOUT_MS: '4000',
69+
SEND_SMS_DRY_RUN: 'true',
70+
DEVSMS_BASE_URL: 'http://env-devsms:4000',
5671
};
5772

5873
const result = getEnvOptions(
@@ -75,7 +90,11 @@ describe('getEnvOptions', () => {
7590
api: {
7691
enableServicesApi: false,
7792
defaultDatabaseId: 'override_db'
78-
}
93+
},
94+
sms: {
95+
senderId: 'OverrideSender',
96+
requestTimeoutMs: 9000,
97+
},
7998
},
8099
tempDir,
81100
testEnv
@@ -121,4 +140,125 @@ describe('getEnvOptions', () => {
121140
expect(result.api?.exposedSchemas).toEqual(['public', 'override_schema']);
122141
expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']);
123142
});
143+
144+
it('parses SMS environment variables into typed options', () => {
145+
const result = getGraphQLEnvVars({
146+
SMS_PROVIDER: 'devsms',
147+
SMS_SENDER_ID: 'LocalSender',
148+
SMS_REQUEST_TIMEOUT_MS: '2500',
149+
SEND_SMS_DRY_RUN: 'true',
150+
DEVSMS_BASE_URL: 'http://localhost:4000',
151+
});
152+
153+
expect(result.sms).toEqual({
154+
provider: 'devsms',
155+
senderId: 'LocalSender',
156+
requestTimeoutMs: 2500,
157+
dryRun: true,
158+
devsms: {
159+
baseUrl: 'http://localhost:4000',
160+
},
161+
});
162+
});
163+
164+
it('accepts custom SMS provider names', () => {
165+
const result = getGraphQLEnvVars({
166+
SMS_PROVIDER: 'custom-sms-gateway',
167+
});
168+
169+
expect(result.sms?.provider).toBe('custom-sms-gateway');
170+
});
171+
172+
it('honors config, env, and runtime override priority for SMS', () => {
173+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-sms-'));
174+
writeConfig(tempDir, {
175+
sms: {
176+
provider: 'devsms',
177+
senderId: 'ConfigSender',
178+
requestTimeoutMs: 3000,
179+
dryRun: false,
180+
devsms: {
181+
baseUrl: 'http://config-devsms:4000',
182+
},
183+
},
184+
});
185+
186+
const result = getEnvOptions(
187+
{
188+
sms: {
189+
requestTimeoutMs: 9000,
190+
},
191+
},
192+
tempDir,
193+
{
194+
SMS_SENDER_ID: 'EnvSender',
195+
SEND_SMS_DRY_RUN: 'true',
196+
DEVSMS_BASE_URL: 'http://env-devsms:4000',
197+
}
198+
);
199+
200+
expect(result.sms).toEqual({
201+
provider: 'devsms',
202+
senderId: 'EnvSender',
203+
requestTimeoutMs: 9000,
204+
dryRun: true,
205+
devsms: {
206+
baseUrl: 'http://env-devsms:4000',
207+
},
208+
});
209+
});
210+
211+
it('uses the injected env object instead of global process.env for SMS', () => {
212+
const previousSmsProvider = process.env.SMS_PROVIDER;
213+
process.env.SMS_PROVIDER = 'twilio';
214+
215+
try {
216+
const result = getEnvOptions({}, process.cwd(), {
217+
SMS_PROVIDER: 'devsms',
218+
});
219+
220+
expect(result.sms?.provider).toBe('devsms');
221+
} finally {
222+
if (previousSmsProvider === undefined) {
223+
delete process.env.SMS_PROVIDER;
224+
} else {
225+
process.env.SMS_PROVIDER = previousSmsProvider;
226+
}
227+
}
228+
});
229+
230+
it('keeps SMS absent when it is not configured', () => {
231+
const result = getEnvOptions({}, process.cwd(), {});
232+
233+
expect(result.sms).toBeUndefined();
234+
});
235+
236+
it('omits an invalid SMS timeout from partial env overrides', () => {
237+
const result = getGraphQLEnvVars({
238+
SMS_REQUEST_TIMEOUT_MS: '5s',
239+
});
240+
241+
expect(result.sms).toBeUndefined();
242+
});
243+
244+
it('does not let absent or invalid SMS env values override config', () => {
245+
tempDir = fs.mkdtempSync(
246+
path.join(os.tmpdir(), 'graphql-env-sms-defaults-')
247+
);
248+
writeConfig(tempDir, {
249+
sms: {
250+
requestTimeoutMs: 3000,
251+
dryRun: true,
252+
},
253+
});
254+
255+
const result = getEnvOptions({}, tempDir, {
256+
SMS_REQUEST_TIMEOUT_MS: '5s',
257+
});
258+
259+
expect(result.sms).toEqual({
260+
requestTimeoutMs: 3000,
261+
dryRun: true,
262+
});
263+
});
124264
});

graphql/env/src/env.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ConstructiveOptions } from '@constructive-io/graphql-types';
2-
import { parseEnvBoolean } from '12factor-env';
2+
import { parseEnvBoolean, parseEnvNumber } from '12factor-env';
33

44
/**
55
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
@@ -26,8 +26,27 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
2626
CHAT_PROVIDER,
2727
CHAT_MODEL,
2828
CHAT_BASE_URL,
29+
30+
SMS_PROVIDER,
31+
SMS_SENDER_ID,
32+
SMS_REQUEST_TIMEOUT_MS,
33+
SEND_SMS_DRY_RUN,
34+
DEVSMS_BASE_URL,
2935
} = env;
3036

37+
// Keep this function as a partial env-override parser. SMS runtime defaults
38+
// belong to the consuming application; injecting them here would incorrectly
39+
// let an absent env var overwrite pgpm.json or consumer-specific values.
40+
const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS);
41+
const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN);
42+
const hasSmsEnvOverrides = Boolean(
43+
SMS_PROVIDER ||
44+
SMS_SENDER_ID ||
45+
smsRequestTimeoutMs !== undefined ||
46+
smsDryRun !== undefined ||
47+
DEVSMS_BASE_URL
48+
);
49+
3150
return {
3251
graphile: {
3352
...(GRAPHILE_SCHEMA && {
@@ -68,5 +87,20 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
6887
}),
6988
},
7089
}),
90+
...(hasSmsEnvOverrides && {
91+
sms: {
92+
...(SMS_PROVIDER && { provider: SMS_PROVIDER }),
93+
...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }),
94+
...(smsRequestTimeoutMs !== undefined && {
95+
requestTimeoutMs: smsRequestTimeoutMs,
96+
}),
97+
...(smsDryRun !== undefined && { dryRun: smsDryRun }),
98+
...(DEVSMS_BASE_URL && {
99+
devsms: {
100+
baseUrl: DEVSMS_BASE_URL,
101+
},
102+
}),
103+
},
104+
}),
71105
};
72106
};

graphql/env/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// Export Constructive-specific env functions
22
export { getEnvOptions, getConstructiveEnvOptions } from './merge';
33
export { getGraphQLEnvVars } from './env';
4+
export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types';

graphql/env/src/merge.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const getEnvOptions = (
3030
const graphqlEnvOptions = getGraphQLEnvVars(env);
3131

3232
// Load config again to get any GraphQL-specific config
33-
// Config files can contain Constructive options (graphile, features, api)
33+
// Config files can contain Constructive options (graphile, features, api, sms)
3434
// even though loadConfigSync returns PgpmOptions type
3535
const configOptions = loadConfigSync(cwd) as Partial<ConstructiveOptions>;
3636

@@ -43,6 +43,7 @@ export const getEnvOptions = (
4343
...(configOptions.graphile && { graphile: configOptions.graphile }),
4444
...(configOptions.features && { features: configOptions.features }),
4545
...(configOptions.api && { api: configOptions.api }),
46+
...(configOptions.sms && { sms: configOptions.sms }),
4647
},
4748
graphqlEnvOptions,
4849
overrides

graphql/types/src/constructive.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
apiDefaults
2020
} from './graphile';
2121
import { LlmOptions } from './llm';
22+
import { SmsOptions } from './sms';
2223

2324
/**
2425
* GraphQL-specific options for Constructive
@@ -59,6 +60,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
5960
jobs?: JobsConfig;
6061
/** LLM provider configuration (embeddings, chat, RAG) */
6162
llm?: LlmOptions;
63+
/** SMS provider configuration */
64+
sms?: SmsOptions;
6265
}
6366

6467
/**

graphql/types/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ export {
2929
LlmEmbedderOptions,
3030
LlmChatOptions
3131
} from './llm';
32+
33+
// Export SMS types
34+
export {
35+
SmsOptions,
36+
DevSmsOptions
37+
} from './sms';

graphql/types/src/sms.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* SMS provider configuration options for Constructive runtimes.
3+
*
4+
* Production providers are intentionally configuration-only here. Runtime
5+
* packages decide which providers they implement and validate that required
6+
* provider-specific values are present before sending.
7+
*/
8+
export interface DevSmsOptions {
9+
/** Base URL for the local DevSms API, e.g. http://localhost:4000 */
10+
baseUrl?: string;
11+
}
12+
13+
export interface SmsOptions {
14+
/** SMS provider implementation to use; runtimes may register custom names. */
15+
provider?: string;
16+
/** Optional sender ID/default source address for providers that support it. */
17+
senderId?: string;
18+
/** Outbound provider HTTP timeout in milliseconds. */
19+
requestTimeoutMs?: number;
20+
/** Validate/render messages without sending them to the provider. */
21+
dryRun?: boolean;
22+
/** DevSms local provider options. */
23+
devsms?: DevSmsOptions;
24+
}

0 commit comments

Comments
 (0)