Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
114 changes: 113 additions & 1 deletion graphql/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
}
}
});

Expand All @@ -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(
Expand All @@ -75,6 +90,10 @@ describe('getEnvOptions', () => {
api: {
enableServicesApi: false,
defaultDatabaseId: 'override_db'
},
sms: {
senderId: 'OverrideSender',
requestTimeoutMs: 9000
}
},
tempDir,
Expand Down Expand Up @@ -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');
});
});
44 changes: 43 additions & 1 deletion graphql/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
*/
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
}),
},
}),
};
};
1 change: 1 addition & 0 deletions graphql/env/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 2 additions & 1 deletion graphql/env/src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConstructiveOptions>;

Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion graphql/types/src/constructive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
apiDefaults
} from './graphile';
import { LlmOptions } from './llm';
import { SmsOptions, smsDefaults } from './sms';

/**
* GraphQL-specific options for Constructive
Expand All @@ -30,6 +31,8 @@ export interface ConstructiveGraphQLOptions {
features?: GraphileFeatureOptions;
/** API configuration options */
api?: ApiOptions;
/** SMS provider configuration */
sms?: SmsOptions;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -67,7 +72,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
export const constructiveGraphqlDefaults: ConstructiveGraphQLOptions = {
graphile: graphileDefaults,
features: graphileFeatureDefaults,
api: apiDefaults
api: apiDefaults,
sms: smsDefaults
};

/**
Expand Down
8 changes: 8 additions & 0 deletions graphql/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@ export {
LlmEmbedderOptions,
LlmChatOptions
} from './llm';

// Export SMS types
export {
SmsProviderName,
SmsOptions,
DevSmsOptions,
smsDefaults
} from './sms';
31 changes: 31 additions & 0 deletions graphql/types/src/sms.ts
Original file line number Diff line number Diff line change
@@ -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
};
Loading