diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 3e27125d0..531cd80ff 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -120,6 +120,15 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "strictAuth": false, "trustProxy": false, }, + "sms": { + "devsms": { + "baseUrl": "http://env-devsms:4000", + }, + "dryRun": true, + "provider": "devsms", + "requestTimeoutMs": 9000, + "senderId": "OverrideSender", + }, "smtp": { "debug": false, "logger": false, diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index edbc3f6df..7670e7c39 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -1,4 +1,5 @@ import { getEnvOptions } from '../src/merge'; +import { getGraphQLEnvVars } from '../src/env'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -37,6 +38,15 @@ describe('getEnvOptions', () => { enableServicesApi: false, isPublic: false, metaSchemas: ['config_meta'] + }, + sms: { + provider: 'devsms', + senderId: 'ConfigSender', + requestTimeoutMs: 3000, + dryRun: false, + devsms: { + baseUrl: 'http://config-devsms:4000' + } } }); @@ -52,7 +62,12 @@ describe('getEnvOptions', () => { API_META_SCHEMAS: 'env_meta1,env_meta2', API_ANON_ROLE: 'env_anon', API_ROLE_NAME: 'env_role', - API_DEFAULT_DATABASE_ID: 'env_db' + API_DEFAULT_DATABASE_ID: 'env_db', + SMS_PROVIDER: 'devsms', + SMS_SENDER_ID: 'EnvSender', + SMS_REQUEST_TIMEOUT_MS: '4000', + SEND_SMS_DRY_RUN: 'true', + DEVSMS_BASE_URL: 'http://env-devsms:4000' }; const result = getEnvOptions( @@ -75,6 +90,10 @@ describe('getEnvOptions', () => { api: { enableServicesApi: false, defaultDatabaseId: 'override_db' + }, + sms: { + senderId: 'OverrideSender', + requestTimeoutMs: 9000 } }, tempDir, @@ -121,4 +140,107 @@ describe('getEnvOptions', () => { expect(result.api?.exposedSchemas).toEqual(['public', 'override_schema']); expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']); }); + + it('parses SMS environment variables into typed options', () => { + const result = getGraphQLEnvVars({ + SMS_PROVIDER: 'devsms', + SMS_SENDER_ID: 'LocalSender', + SMS_REQUEST_TIMEOUT_MS: '2500', + SEND_SMS_DRY_RUN: 'true', + DEVSMS_BASE_URL: 'http://localhost:4000' + }); + + expect(result.sms).toEqual({ + provider: 'devsms', + senderId: 'LocalSender', + requestTimeoutMs: 2500, + dryRun: true, + devsms: { + baseUrl: 'http://localhost:4000' + } + }); + }); + + it('accepts custom SMS provider names', () => { + const result = getGraphQLEnvVars({ + SMS_PROVIDER: 'custom-sms-gateway' + }); + + expect(result.sms?.provider).toBe('custom-sms-gateway'); + }); + + it('honors defaults, config, env, and runtime override priority for SMS', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-sms-')); + writeConfig(tempDir, { + sms: { + provider: 'devsms', + senderId: 'ConfigSender', + requestTimeoutMs: 3000, + dryRun: false, + devsms: { + baseUrl: 'http://config-devsms:4000' + } + } + }); + + const result = getEnvOptions( + { + sms: { + requestTimeoutMs: 9000 + } + }, + tempDir, + { + SMS_SENDER_ID: 'EnvSender', + SEND_SMS_DRY_RUN: 'true', + DEVSMS_BASE_URL: 'http://env-devsms:4000' + } + ); + + expect(result.sms).toEqual({ + provider: 'devsms', + senderId: 'EnvSender', + requestTimeoutMs: 9000, + dryRun: true, + devsms: { + baseUrl: 'http://env-devsms:4000' + } + }); + }); + + it('uses the injected env object instead of global process.env for SMS', () => { + const previousSmsProvider = process.env.SMS_PROVIDER; + process.env.SMS_PROVIDER = 'twilio'; + + try { + const result = getEnvOptions({}, process.cwd(), { + SMS_PROVIDER: 'devsms' + }); + + expect(result.sms?.provider).toBe('devsms'); + } finally { + if (previousSmsProvider === undefined) { + delete process.env.SMS_PROVIDER; + } else { + process.env.SMS_PROVIDER = previousSmsProvider; + } + } + }); + + it('keeps SMS provider and DevSms base URL optional while applying defaults', () => { + const result = getEnvOptions({}, process.cwd(), {}); + + expect(result.sms).toEqual({ + requestTimeoutMs: 5000, + dryRun: false + }); + }); + + it('uses shared number parsing behavior for invalid SMS_REQUEST_TIMEOUT_MS values', () => { + const result = getGraphQLEnvVars({ + SMS_REQUEST_TIMEOUT_MS: '5s' + }); + + expect(result.sms?.requestTimeoutMs).toBeUndefined(); + }); }); diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 2ea2ad5c7..9d542166a 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,4 +1,5 @@ -import { ConstructiveOptions } from '@constructive-io/graphql-types'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { parseEnvNumber } from '@pgpmjs/env'; import { parseEnvBoolean } from '12factor-env'; /** @@ -26,6 +27,12 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial CHAT_PROVIDER, CHAT_MODEL, CHAT_BASE_URL, + + SMS_PROVIDER, + SMS_SENDER_ID, + SMS_REQUEST_TIMEOUT_MS, + SEND_SMS_DRY_RUN, + DEVSMS_BASE_URL, } = env; return { @@ -68,5 +75,20 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial }), }, }), + ...((SMS_PROVIDER || SMS_SENDER_ID || SMS_REQUEST_TIMEOUT_MS || SEND_SMS_DRY_RUN || DEVSMS_BASE_URL) && { + sms: { + ...(SMS_PROVIDER && { provider: SMS_PROVIDER }), + ...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }), + ...(SMS_REQUEST_TIMEOUT_MS && { + requestTimeoutMs: parseEnvNumber(SMS_REQUEST_TIMEOUT_MS) + }), + ...(SEND_SMS_DRY_RUN && { dryRun: parseEnvBoolean(SEND_SMS_DRY_RUN) }), + ...(DEVSMS_BASE_URL && { + devsms: { + baseUrl: DEVSMS_BASE_URL + } + }), + }, + }), }; }; diff --git a/graphql/env/src/index.ts b/graphql/env/src/index.ts index cfe0e18f1..b19937632 100644 --- a/graphql/env/src/index.ts +++ b/graphql/env/src/index.ts @@ -1,3 +1,4 @@ // Export Constructive-specific env functions export { getEnvOptions, getConstructiveEnvOptions } from './merge'; export { getGraphQLEnvVars } from './env'; +export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types'; diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 669c75269..ef661777c 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -30,7 +30,7 @@ export const getEnvOptions = ( const graphqlEnvOptions = getGraphQLEnvVars(env); // Load config again to get any GraphQL-specific config - // Config files can contain Constructive options (graphile, features, api) + // Config files can contain Constructive options (graphile, features, api, sms) // even though loadConfigSync returns PgpmOptions type const configOptions = loadConfigSync(cwd) as Partial; @@ -43,6 +43,7 @@ export const getEnvOptions = ( ...(configOptions.graphile && { graphile: configOptions.graphile }), ...(configOptions.features && { features: configOptions.features }), ...(configOptions.api && { api: configOptions.api }), + ...(configOptions.sms && { sms: configOptions.sms }), }, graphqlEnvOptions, overrides diff --git a/graphql/types/src/constructive.ts b/graphql/types/src/constructive.ts index fc64c547c..b3d0e5747 100644 --- a/graphql/types/src/constructive.ts +++ b/graphql/types/src/constructive.ts @@ -19,6 +19,7 @@ import { apiDefaults } from './graphile'; import { LlmOptions } from './llm'; +import { SmsOptions, smsDefaults } from './sms'; /** * GraphQL-specific options for Constructive @@ -30,6 +31,8 @@ export interface ConstructiveGraphQLOptions { features?: GraphileFeatureOptions; /** API configuration options */ api?: ApiOptions; + /** SMS provider configuration */ + sms?: SmsOptions; } /** @@ -59,6 +62,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt jobs?: JobsConfig; /** LLM provider configuration (embeddings, chat, RAG) */ llm?: LlmOptions; + /** SMS provider configuration */ + sms?: SmsOptions; } /** @@ -67,7 +72,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt export const constructiveGraphqlDefaults: ConstructiveGraphQLOptions = { graphile: graphileDefaults, features: graphileFeatureDefaults, - api: apiDefaults + api: apiDefaults, + sms: smsDefaults }; /** diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index a66eb0bf6..9b51f1532 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -29,3 +29,10 @@ export { LlmEmbedderOptions, LlmChatOptions } from './llm'; + +// Export SMS types +export { + SmsOptions, + DevSmsOptions, + smsDefaults +} from './sms'; diff --git a/graphql/types/src/sms.ts b/graphql/types/src/sms.ts new file mode 100644 index 000000000..18ba95f50 --- /dev/null +++ b/graphql/types/src/sms.ts @@ -0,0 +1,29 @@ +/** + * SMS provider configuration options for Constructive runtimes. + * + * Production providers are intentionally configuration-only here. Runtime + * packages decide which providers they implement and validate that required + * provider-specific values are present before sending. + */ +export interface DevSmsOptions { + /** Base URL for the local DevSms API, e.g. http://localhost:4000 */ + baseUrl?: string; +} + +export interface SmsOptions { + /** SMS provider implementation to use; runtimes may register custom names. */ + provider?: string; + /** Optional sender ID/default source address for providers that support it. */ + senderId?: string; + /** Outbound provider HTTP timeout in milliseconds. */ + requestTimeoutMs?: number; + /** Validate/render messages without sending them to the provider. */ + dryRun?: boolean; + /** DevSms local provider options. */ + devsms?: DevSmsOptions; +} + +export const smsDefaults: SmsOptions = { + requestTimeoutMs: 5000, + dryRun: false +};