Skip to content

Commit 7c96378

Browse files
committed
feat: add typed SMS configuration flow
1 parent d375791 commit 7c96378

8 files changed

Lines changed: 214 additions & 4 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
@@ -113,6 +113,15 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
113113
"strictAuth": false,
114114
"trustProxy": false,
115115
},
116+
"sms": {
117+
"devsms": {
118+
"baseUrl": "http://env-devsms:4000",
119+
},
120+
"dryRun": true,
121+
"provider": "devsms",
122+
"requestTimeoutMs": 9000,
123+
"senderId": "OverrideSender",
124+
},
116125
"smtp": {
117126
"debug": false,
118127
"logger": false,

graphql/env/__tests__/merge.test.ts

Lines changed: 113 additions & 1 deletion
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,6 +38,15 @@ describe('getEnvOptions', () => {
3738
enableServicesApi: false,
3839
isPublic: false,
3940
metaSchemas: ['config_meta']
41+
},
42+
sms: {
43+
provider: 'devsms',
44+
senderId: 'ConfigSender',
45+
requestTimeoutMs: 3000,
46+
dryRun: false,
47+
devsms: {
48+
baseUrl: 'http://config-devsms:4000'
49+
}
4050
}
4151
});
4252

@@ -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,6 +90,10 @@ describe('getEnvOptions', () => {
7590
api: {
7691
enableServicesApi: false,
7792
defaultDatabaseId: 'override_db'
93+
},
94+
sms: {
95+
senderId: 'OverrideSender',
96+
requestTimeoutMs: 9000
7897
}
7998
},
8099
tempDir,
@@ -121,4 +140,97 @@ 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('honors defaults, config, env, and runtime override priority for SMS', () => {
165+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-sms-'));
166+
writeConfig(tempDir, {
167+
sms: {
168+
provider: 'devsms',
169+
senderId: 'ConfigSender',
170+
requestTimeoutMs: 3000,
171+
dryRun: false,
172+
devsms: {
173+
baseUrl: 'http://config-devsms:4000'
174+
}
175+
}
176+
});
177+
178+
const result = getEnvOptions(
179+
{
180+
sms: {
181+
requestTimeoutMs: 9000
182+
}
183+
},
184+
tempDir,
185+
{
186+
SMS_SENDER_ID: 'EnvSender',
187+
SEND_SMS_DRY_RUN: 'true',
188+
DEVSMS_BASE_URL: 'http://env-devsms:4000'
189+
}
190+
);
191+
192+
expect(result.sms).toEqual({
193+
provider: 'devsms',
194+
senderId: 'EnvSender',
195+
requestTimeoutMs: 9000,
196+
dryRun: true,
197+
devsms: {
198+
baseUrl: 'http://env-devsms:4000'
199+
}
200+
});
201+
});
202+
203+
it('uses the injected env object instead of global process.env for SMS', () => {
204+
const previousSmsProvider = process.env.SMS_PROVIDER;
205+
process.env.SMS_PROVIDER = 'twilio';
206+
207+
try {
208+
const result = getEnvOptions({}, process.cwd(), {
209+
SMS_PROVIDER: 'devsms'
210+
});
211+
212+
expect(result.sms?.provider).toBe('devsms');
213+
} finally {
214+
if (previousSmsProvider === undefined) {
215+
delete process.env.SMS_PROVIDER;
216+
} else {
217+
process.env.SMS_PROVIDER = previousSmsProvider;
218+
}
219+
}
220+
});
221+
222+
it('keeps SMS provider and DevSms base URL optional while applying defaults', () => {
223+
const result = getEnvOptions({}, process.cwd(), {});
224+
225+
expect(result.sms).toEqual({
226+
requestTimeoutMs: 5000,
227+
dryRun: false
228+
});
229+
});
230+
231+
it('throws on invalid SMS_REQUEST_TIMEOUT_MS values', () => {
232+
expect(() => getGraphQLEnvVars({
233+
SMS_REQUEST_TIMEOUT_MS: '5s'
234+
})).toThrow('SMS_REQUEST_TIMEOUT_MS must be an integer');
235+
});
124236
});

graphql/env/src/env.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ConstructiveOptions } from '@constructive-io/graphql-types';
1+
import type { ConstructiveOptions, SmsProviderName } from '@constructive-io/graphql-types';
22

33
/**
44
* Parse GraphQL-related environment variables.
@@ -9,6 +9,27 @@ const parseEnvBoolean = (val?: string): boolean | undefined => {
99
return ['true', '1', 'yes'].includes(val.toLowerCase());
1010
};
1111

12+
const parseEnvInteger = (name: string, val?: string): number | undefined => {
13+
if (val === undefined) return undefined;
14+
const trimmed = val.trim();
15+
if (!/^\d+$/.test(trimmed)) {
16+
throw new Error(`${name} must be an integer`);
17+
}
18+
const parsed = Number.parseInt(trimmed, 10);
19+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
20+
throw new Error(`${name} must be a positive safe integer`);
21+
}
22+
return parsed;
23+
};
24+
25+
const parseSmsProvider = (val?: string): SmsProviderName | undefined => {
26+
if (val === undefined) return undefined;
27+
if (val === 'devsms' || val === 'twilio' || val === 'sns') {
28+
return val;
29+
}
30+
throw new Error('SMS_PROVIDER must be one of: devsms, twilio, sns');
31+
};
32+
1233
/**
1334
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
1435
*/
@@ -34,6 +55,12 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
3455
CHAT_PROVIDER,
3556
CHAT_MODEL,
3657
CHAT_BASE_URL,
58+
59+
SMS_PROVIDER,
60+
SMS_SENDER_ID,
61+
SMS_REQUEST_TIMEOUT_MS,
62+
SEND_SMS_DRY_RUN,
63+
DEVSMS_BASE_URL,
3764
} = env;
3865

3966
return {
@@ -76,5 +103,20 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
76103
}),
77104
},
78105
}),
106+
...((SMS_PROVIDER || SMS_SENDER_ID || SMS_REQUEST_TIMEOUT_MS || SEND_SMS_DRY_RUN || DEVSMS_BASE_URL) && {
107+
sms: {
108+
...(SMS_PROVIDER && { provider: parseSmsProvider(SMS_PROVIDER) }),
109+
...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }),
110+
...(SMS_REQUEST_TIMEOUT_MS && {
111+
requestTimeoutMs: parseEnvInteger('SMS_REQUEST_TIMEOUT_MS', SMS_REQUEST_TIMEOUT_MS)
112+
}),
113+
...(SEND_SMS_DRY_RUN && { dryRun: parseEnvBoolean(SEND_SMS_DRY_RUN) }),
114+
...(DEVSMS_BASE_URL && {
115+
devsms: {
116+
baseUrl: DEVSMS_BASE_URL
117+
}
118+
}),
119+
},
120+
}),
79121
};
80122
};

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, SmsProviderName } 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: 7 additions & 1 deletion
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, smsDefaults } from './sms';
2223

2324
/**
2425
* GraphQL-specific options for Constructive
@@ -30,6 +31,8 @@ export interface ConstructiveGraphQLOptions {
3031
features?: GraphileFeatureOptions;
3132
/** API configuration options */
3233
api?: ApiOptions;
34+
/** SMS provider configuration */
35+
sms?: SmsOptions;
3336
}
3437

3538
/**
@@ -59,6 +62,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
5962
jobs?: JobsConfig;
6063
/** LLM provider configuration (embeddings, chat, RAG) */
6164
llm?: LlmOptions;
65+
/** SMS provider configuration */
66+
sms?: SmsOptions;
6267
}
6368

6469
/**
@@ -67,7 +72,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
6772
export const constructiveGraphqlDefaults: ConstructiveGraphQLOptions = {
6873
graphile: graphileDefaults,
6974
features: graphileFeatureDefaults,
70-
api: apiDefaults
75+
api: apiDefaults,
76+
sms: smsDefaults
7177
};
7278

7379
/**

graphql/types/src/index.ts

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

graphql/types/src/sms.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 type SmsProviderName = 'devsms' | 'twilio' | 'sns';
9+
10+
export interface DevSmsOptions {
11+
/** Base URL for the local DevSms API, e.g. http://localhost:4000 */
12+
baseUrl?: string;
13+
}
14+
15+
export interface SmsOptions {
16+
/** SMS provider implementation to use. */
17+
provider?: SmsProviderName;
18+
/** Optional sender ID/default source address for providers that support it. */
19+
senderId?: string;
20+
/** Outbound provider HTTP timeout in milliseconds. */
21+
requestTimeoutMs?: number;
22+
/** Validate/render messages without sending them to the provider. */
23+
dryRun?: boolean;
24+
/** DevSms local provider options. */
25+
devsms?: DevSmsOptions;
26+
}
27+
28+
export const smsDefaults: SmsOptions = {
29+
requestTimeoutMs: 5000,
30+
dryRun: false
31+
};

0 commit comments

Comments
 (0)