From 7c96378a2230ee4a0b185659e6345cc305b47e28 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Fri, 17 Jul 2026 17:03:40 +0800 Subject: [PATCH 1/3] feat: add typed SMS configuration flow --- .../__snapshots__/merge.test.ts.snap | 9 ++ graphql/env/__tests__/merge.test.ts | 114 +++++++++++++++++- graphql/env/src/env.ts | 44 ++++++- graphql/env/src/index.ts | 1 + graphql/env/src/merge.ts | 3 +- graphql/types/src/constructive.ts | 8 +- graphql/types/src/index.ts | 8 ++ graphql/types/src/sms.ts | 31 +++++ 8 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 graphql/types/src/sms.ts diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index b6969aaf51..3b3a0c473d 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -113,6 +113,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 edbc3f6df6..b3b0ad6eea 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,97 @@ 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('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('throws on invalid SMS_REQUEST_TIMEOUT_MS values', () => { + expect(() => getGraphQLEnvVars({ + SMS_REQUEST_TIMEOUT_MS: '5s' + })).toThrow('SMS_REQUEST_TIMEOUT_MS must be an integer'); + }); }); diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index fdf8d62c29..722e2d64c3 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,4 +1,4 @@ -import { ConstructiveOptions } from '@constructive-io/graphql-types'; +import type { ConstructiveOptions, SmsProviderName } from '@constructive-io/graphql-types'; /** * Parse GraphQL-related environment variables. @@ -9,6 +9,27 @@ const parseEnvBoolean = (val?: string): boolean | undefined => { return ['true', '1', 'yes'].includes(val.toLowerCase()); }; +const parseEnvInteger = (name: string, val?: string): number | undefined => { + if (val === undefined) return undefined; + const trimmed = val.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new Error(`${name} must be an integer`); + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive safe integer`); + } + return parsed; +}; + +const parseSmsProvider = (val?: string): SmsProviderName | undefined => { + if (val === undefined) return undefined; + if (val === 'devsms' || val === 'twilio' || val === 'sns') { + return val; + } + throw new Error('SMS_PROVIDER must be one of: devsms, twilio, sns'); +}; + /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ @@ -34,6 +55,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 { @@ -76,5 +103,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: parseSmsProvider(SMS_PROVIDER) }), + ...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }), + ...(SMS_REQUEST_TIMEOUT_MS && { + requestTimeoutMs: parseEnvInteger('SMS_REQUEST_TIMEOUT_MS', 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 cfe0e18f11..6a8ef9ec7e 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, SmsProviderName } from '@constructive-io/graphql-types'; diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 669c75269f..ef661777cb 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 fc64c547c0..b3d0e5747f 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 a66eb0bf63..8b5647dff7 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -29,3 +29,11 @@ export { LlmEmbedderOptions, LlmChatOptions } from './llm'; + +// Export SMS types +export { + SmsProviderName, + SmsOptions, + DevSmsOptions, + smsDefaults +} from './sms'; diff --git a/graphql/types/src/sms.ts b/graphql/types/src/sms.ts new file mode 100644 index 0000000000..90c7833a7b --- /dev/null +++ b/graphql/types/src/sms.ts @@ -0,0 +1,31 @@ +/** + * 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 type SmsProviderName = 'devsms' | 'twilio' | 'sns'; + +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. */ + provider?: SmsProviderName; + /** 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 +}; From 4cff39f8fae6baa6cc1315bb263603a6b0e5c26f Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 21 Jul 2026 08:55:55 +0800 Subject: [PATCH 2/3] refactor: allow custom SMS provider names --- graphql/env/__tests__/merge.test.ts | 8 ++++++++ graphql/env/src/env.ts | 12 ++---------- graphql/env/src/index.ts | 2 +- graphql/types/src/index.ts | 1 - graphql/types/src/sms.ts | 6 ++---- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index b3b0ad6eea..db49bb5f78 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -161,6 +161,14 @@ describe('getEnvOptions', () => { }); }); + 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, { diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 722e2d64c3..8ee56bcda1 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,4 +1,4 @@ -import type { ConstructiveOptions, SmsProviderName } from '@constructive-io/graphql-types'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; /** * Parse GraphQL-related environment variables. @@ -22,14 +22,6 @@ const parseEnvInteger = (name: string, val?: string): number | undefined => { return parsed; }; -const parseSmsProvider = (val?: string): SmsProviderName | undefined => { - if (val === undefined) return undefined; - if (val === 'devsms' || val === 'twilio' || val === 'sns') { - return val; - } - throw new Error('SMS_PROVIDER must be one of: devsms, twilio, sns'); -}; - /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ @@ -105,7 +97,7 @@ 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: parseSmsProvider(SMS_PROVIDER) }), + ...(SMS_PROVIDER && { provider: SMS_PROVIDER }), ...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }), ...(SMS_REQUEST_TIMEOUT_MS && { requestTimeoutMs: parseEnvInteger('SMS_REQUEST_TIMEOUT_MS', SMS_REQUEST_TIMEOUT_MS) diff --git a/graphql/env/src/index.ts b/graphql/env/src/index.ts index 6a8ef9ec7e..b199376324 100644 --- a/graphql/env/src/index.ts +++ b/graphql/env/src/index.ts @@ -1,4 +1,4 @@ // Export Constructive-specific env functions export { getEnvOptions, getConstructiveEnvOptions } from './merge'; export { getGraphQLEnvVars } from './env'; -export type { DevSmsOptions, SmsOptions, SmsProviderName } from '@constructive-io/graphql-types'; +export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types'; diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 8b5647dff7..9b51f15325 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -32,7 +32,6 @@ export { // Export SMS types export { - SmsProviderName, SmsOptions, DevSmsOptions, smsDefaults diff --git a/graphql/types/src/sms.ts b/graphql/types/src/sms.ts index 90c7833a7b..18ba95f503 100644 --- a/graphql/types/src/sms.ts +++ b/graphql/types/src/sms.ts @@ -5,16 +5,14 @@ * packages decide which providers they implement and validate that required * provider-specific values are present before sending. */ -export type SmsProviderName = 'devsms' | 'twilio' | 'sns'; - 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. */ - provider?: SmsProviderName; + /** 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. */ From 4218afb8a91ebd3b2f7b4e353eeb2d5878d07c35 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 21 Jul 2026 09:08:10 +0800 Subject: [PATCH 3/3] reuse shared env number parser --- graphql/env/__tests__/merge.test.ts | 8 +++++--- graphql/env/src/env.ts | 16 ++-------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index db49bb5f78..7670e7c395 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -236,9 +236,11 @@ describe('getEnvOptions', () => { }); }); - it('throws on invalid SMS_REQUEST_TIMEOUT_MS values', () => { - expect(() => getGraphQLEnvVars({ + it('uses shared number parsing behavior for invalid SMS_REQUEST_TIMEOUT_MS values', () => { + const result = getGraphQLEnvVars({ SMS_REQUEST_TIMEOUT_MS: '5s' - })).toThrow('SMS_REQUEST_TIMEOUT_MS must be an integer'); + }); + + expect(result.sms?.requestTimeoutMs).toBeUndefined(); }); }); diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 8ee56bcda1..ddc6d03957 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,4 +1,5 @@ import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { parseEnvNumber } from '@pgpmjs/env'; /** * Parse GraphQL-related environment variables. @@ -9,19 +10,6 @@ const parseEnvBoolean = (val?: string): boolean | undefined => { return ['true', '1', 'yes'].includes(val.toLowerCase()); }; -const parseEnvInteger = (name: string, val?: string): number | undefined => { - if (val === undefined) return undefined; - const trimmed = val.trim(); - if (!/^\d+$/.test(trimmed)) { - throw new Error(`${name} must be an integer`); - } - const parsed = Number.parseInt(trimmed, 10); - if (!Number.isSafeInteger(parsed) || parsed <= 0) { - throw new Error(`${name} must be a positive safe integer`); - } - return parsed; -}; - /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ @@ -100,7 +88,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial ...(SMS_PROVIDER && { provider: SMS_PROVIDER }), ...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }), ...(SMS_REQUEST_TIMEOUT_MS && { - requestTimeoutMs: parseEnvInteger('SMS_REQUEST_TIMEOUT_MS', SMS_REQUEST_TIMEOUT_MS) + requestTimeoutMs: parseEnvNumber(SMS_REQUEST_TIMEOUT_MS) }), ...(SEND_SMS_DRY_RUN && { dryRun: parseEnvBoolean(SEND_SMS_DRY_RUN) }), ...(DEVSMS_BASE_URL && {