From d2e7f2a91076b688c7dc44a4c3f17f56c2d6be5a Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 17 Oct 2025 10:54:38 +0200 Subject: [PATCH] 1635 extensibilitytwenty cli app vars (#15143) - Update twenty-cli to support application env variable definition - Update twenty-server to create a new `core.applicationVariable` entity to store env variables and provide env var when executing serverless function - Update twenty-front to support application environment variable value setting image image image image image image --- packages/twenty-apps/hello-world/.gitignore | 1 + packages/twenty-apps/hello-world/package.json | 10 + .../create-new-post-card/src/index.ts | 7 +- packages/twenty-cli/package.json | 2 +- .../schemas/appManifest.schema.json | 28 +++ .../constants/base-application-project/.env | 15 ++ .../base-application-project/.gitignore | 1 + .../base-application-project/package.json | 5 +- .../src/utils/app-manifest-loader.ts | 39 +++- .../twenty-e2e-testing/playwright.config.ts | 2 +- packages/twenty-front/codegen-metadata.cjs | 1 + .../src/generated-metadata/graphql.ts | 80 +++++++- .../twenty-front/src/generated/graphql.ts | 19 ++ .../mutations/updateOneApplicationVariable.ts | 15 ++ .../graphql/fragments/applicationFragment.ts | 8 + .../graphql/queries/findManyApplications.ts | 1 + .../SettingsServerlessFunctionCodeEditor.tsx | 9 +- .../SettingsServerlessFunctionNewForm.tsx | 2 +- ...FunctionTabEnvironmentVariablesSection.tsx | 33 +++ ...ettingsServerlessFunctionCodeEditorTab.tsx | 8 +- .../SettingsServerlessFunctionSettingsTab.tsx | 11 +- ...FunctionTabEnvironmentVariableTableRow.tsx | 2 +- ...FunctionTabEnvironmentVariablesSection.tsx | 147 ------------- .../useServerlessFunctionUpdateFormState.ts | 20 +- .../serverlessFunctionEnvVarFamilyState.ts | 10 - .../SettingsApplicationDetails.tsx | 120 +++++------ ...ettingsApplicationDetailSkeletonLoader.tsx | 31 --- .../SettingsApplicationVersionContainer.tsx | 53 +++++ .../hooks/useUpdateOneApplicationVariable.ts | 29 +++ .../SettingsApplicationDetailAboutTab.tsx | 52 +++++ .../SettingsApplicationDetailContentTab.tsx | 75 +++++++ ...icationDetailEnvironmentVariablesTable.tsx | 77 +++++++ ...tionDetailEnvironmentVariablesTableRow.tsx | 143 +++++++++++++ .../SettingsApplicationDetailSettingsTab.tsx | 33 +++ .../SettingsServerlessFunctionDetail.tsx | 37 ++-- ...844181-addApplicationVariableCoreEntity.ts | 23 +++ .../application/application-sync.service.ts | 16 +- .../application/application.entity.ts | 10 + .../application/application.module.ts | 2 + .../application/application.service.ts | 14 +- .../application/dtos/application.dto.ts | 8 + .../application/types/application.types.ts | 9 + .../application-variable-exception-filter.ts | 21 ++ .../application-variable.entity.ts | 65 ++++++ .../application-variable.exception.ts | 7 + .../application-variable.module.ts | 14 ++ .../application-variable.resolver.ts | 25 +++ .../application-variable.service.ts | 58 ++++++ .../dtos/application-variable.dto.ts | 28 +++ .../dtos/update-application-variable.input.ts | 15 ++ .../drivers/constants/executor/index.mjs | 7 +- .../serverless/drivers/lambda.driver.ts | 2 + .../serverless/drivers/local.driver.ts | 194 ++++++++++++++++-- .../serverless/drivers/utils/build-env-var.ts | 12 ++ .../serverless-function.service.ts | 5 +- .../types/serverless-function-code.type.ts | 1 - .../display/icon/components/TablerIcons.ts | 1 + packages/twenty-ui/src/display/index.ts | 1 + 58 files changed, 1305 insertions(+), 359 deletions(-) create mode 100644 packages/twenty-cli/src/constants/base-application-project/.env create mode 100644 packages/twenty-front/src/modules/application-variables/graphql/mutations/updateOneApplicationVariable.ts create mode 100644 packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionTabEnvironmentVariablesSection.tsx delete mode 100644 packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection.tsx delete mode 100644 packages/twenty-front/src/modules/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState.ts create mode 100644 packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/hooks/useUpdateOneApplicationVariable.ts create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1760640844181-addApplicationVariableCoreEntity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable-exception-filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.resolver.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/application-variable.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/update-application-variable.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-env-var.ts diff --git a/packages/twenty-apps/hello-world/.gitignore b/packages/twenty-apps/hello-world/.gitignore index deb6b0a060..4f0e650be6 100644 --- a/packages/twenty-apps/hello-world/.gitignore +++ b/packages/twenty-apps/hello-world/.gitignore @@ -1 +1,2 @@ .yarn/install-state.gz +.env diff --git a/packages/twenty-apps/hello-world/package.json b/packages/twenty-apps/hello-world/package.json index 65fff9b1b9..43c1f5b49c 100644 --- a/packages/twenty-apps/hello-world/package.json +++ b/packages/twenty-apps/hello-world/package.json @@ -11,7 +11,17 @@ "universalIdentifier": "4ec0391d-18d5-411c-b2f3-266ddc1c3ef7", "name": "Hello world", "description": "A hello-world application example", + "env": { + "TWENTY_API_KEY": { + "isSecret": true, + "value": "", + "description": "Twenty api key" + } + }, "dependencies": { "axios": "^1.12.2" + }, + "devDependencies": { + "@types/node": "^24.7.2" } } diff --git a/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts b/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts index af2956b2e5..05f7a8f57b 100644 --- a/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts +++ b/packages/twenty-apps/hello-world/serverlessFunctions/create-new-post-card/src/index.ts @@ -1,7 +1,5 @@ import axios from 'axios'; -const TWENTY_API_KEY = ''; - export const main = async (params: { recipient: string }): Promise => { const { recipient } = params; @@ -10,7 +8,7 @@ export const main = async (params: { recipient: string }): Promise => { url: 'http://localhost:3000/rest/postCards', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${TWENTY_API_KEY}`, + Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, }, data: { name: recipient ?? 'Unknown' }, }; @@ -20,8 +18,9 @@ export const main = async (params: { recipient: string }): Promise => { console.log(`New post card to "${recipient}" created`); - return { data }; + return data; } catch (error) { console.error(error); + throw error; } }; diff --git a/packages/twenty-cli/package.json b/packages/twenty-cli/package.json index 8371498489..1eb6494b47 100644 --- a/packages/twenty-cli/package.json +++ b/packages/twenty-cli/package.json @@ -1,6 +1,6 @@ { "name": "twenty-cli", - "version": "0.1.2-alpha", + "version": "0.1.2-beta", "description": "Command-line interface for Twenty application development", "main": "dist/cli.js", "bin": { diff --git a/packages/twenty-cli/schemas/appManifest.schema.json b/packages/twenty-cli/schemas/appManifest.schema.json index f661c443de..410e9b60c5 100644 --- a/packages/twenty-cli/schemas/appManifest.schema.json +++ b/packages/twenty-cli/schemas/appManifest.schema.json @@ -36,6 +36,34 @@ "title": "The application's license", "description": "Currently only MIT is accepted, although more licenses will probably be available in the future." }, + "env": { + "type": "object", + "title": "Environment Variables", + "description": "Key-value pairs defining environment variables available to all serverless functions.", + "patternProperties": { + "^[A-Z_][A-Z0-9_]*$": { + "type": "object", + "title": "Environment Variable Definition", + "properties": { + "description": { + "type": "string", + "description": "Description for this environment variable." + }, + "value": { + "type": "string", + "description": "Default value for this environment variable" + }, + "isSecret": { + "type": "boolean", + "description": "If true, the value will be treated as sensitive and hidden from logs or UI." + } + }, + "required": ["isSecret"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, "engines": { "type": "object", "title": "The application's engines", diff --git a/packages/twenty-cli/src/constants/base-application-project/.env b/packages/twenty-cli/src/constants/base-application-project/.env new file mode 100644 index 0000000000..ef633038da --- /dev/null +++ b/packages/twenty-cli/src/constants/base-application-project/.env @@ -0,0 +1,15 @@ +# Set environment values for your application here. +# Use the format: KEY=value +# +# These variables are automatically loaded when running your serverless functions. +# You can access them directly in your code using: +# const myValue = process.env.KEY; +# +# To make these variables available to your application, +# add them to package.json "env" key. This "env" key defines all +# environment variables that will be provided to your serverless +# functions at runtime. +# +# Example: +# API_TOKEN=your-api-token +# TIMEOUT_MS=3000 diff --git a/packages/twenty-cli/src/constants/base-application-project/.gitignore b/packages/twenty-cli/src/constants/base-application-project/.gitignore index deb6b0a060..4f0e650be6 100644 --- a/packages/twenty-cli/src/constants/base-application-project/.gitignore +++ b/packages/twenty-cli/src/constants/base-application-project/.gitignore @@ -1 +1,2 @@ .yarn/install-state.gz +.env diff --git a/packages/twenty-cli/src/constants/base-application-project/package.json b/packages/twenty-cli/src/constants/base-application-project/package.json index 55e7dd1628..adda602a1c 100644 --- a/packages/twenty-cli/src/constants/base-application-project/package.json +++ b/packages/twenty-cli/src/constants/base-application-project/package.json @@ -6,5 +6,8 @@ "npm": "please-use-yarn", "yarn": ">=4.0.2" }, - "packageManager": "yarn@4.9.2" + "packageManager": "yarn@4.9.2", + "devDependencies": { + "@types/node": "^24.7.2" + } } diff --git a/packages/twenty-cli/src/utils/app-manifest-loader.ts b/packages/twenty-cli/src/utils/app-manifest-loader.ts index 610206708c..4960991187 100644 --- a/packages/twenty-cli/src/utils/app-manifest-loader.ts +++ b/packages/twenty-cli/src/utils/app-manifest-loader.ts @@ -1,3 +1,4 @@ +import dotenv from 'dotenv'; import assert from 'assert'; import * as fs from 'fs-extra'; import * as path from 'path'; @@ -111,12 +112,44 @@ export const loadManifest = async ( manifest: AppManifest; }> => { const packageJsonPath = await findPathFile(appPath, 'package.json'); + const rawPackageJson = await parseJsoncFile(packageJsonPath); const yarnLockPath = await findPathFile(appPath, 'yarn.lock'); + const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8'); - await validateSchema('appManifest', rawPackageJson, packageJsonPath); + let envFile = ''; + + try { + const envFilePath = await findPathFile(appPath, '.env'); + + envFile = await fs.readFile(envFilePath, 'utf8'); + } catch { + // Allow missing .env + } + + const envVariables = dotenv.parse(envFile); + + const packageJsonEnv = rawPackageJson.env || {}; + + for (const key of Object.keys(envVariables)) { + if (packageJsonEnv[key]) { + packageJsonEnv[key] = { + isSecret: false, + ...packageJsonEnv[key], + value: envVariables[key], + }; + } else { + throw new Error( + `Environment variable "${key}" is defined in .env but missing from package.json. Please add it to the "env" section in package.json.`, + ); + } + } + + const packageJson = { ...rawPackageJson, env: packageJsonEnv }; + + await validateSchema('appManifest', packageJson, packageJsonPath); const agents = await loadCoreEntity( path.join(appPath, 'agents'), @@ -134,10 +167,10 @@ export const loadManifest = async ( ); return { - packageJson: rawPackageJson, + packageJson, yarnLock: rawYarnLock, manifest: { - ...rawPackageJson, + ...packageJson, agents, objects, serverlessFunctions, diff --git a/packages/twenty-e2e-testing/playwright.config.ts b/packages/twenty-e2e-testing/playwright.config.ts index 6fea9a7021..ecce959226 100644 --- a/packages/twenty-e2e-testing/playwright.config.ts +++ b/packages/twenty-e2e-testing/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; import { config } from 'dotenv'; -import path from 'path'; +import * as path from 'path'; const envResult = config({ path: path.resolve(__dirname, '.env'), diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index d2bee36cb4..8e025eeee2 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -10,6 +10,7 @@ module.exports = { './src/modules/views/graphql/**/*.{ts,tsx}', './src/modules/ai/graphql/**/*.{ts,tsx}', './src/modules/applications/graphql/**/*.{ts,tsx}', + './src/modules/application-variables/graphql/**/*.{ts,tsx}', './src/modules/workspace/graphql/**/*.{ts,tsx}', './src/modules/workspace-member/graphql/**/*.{ts,tsx}', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 88d600fa29..ee43152eb1 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -204,11 +204,22 @@ export type AppTokenEdge = { export type Application = { __typename?: 'Application'; agents: Array; + applicationVariables: Array; description: Scalars['String']; id: Scalars['UUID']; name: Scalars['String']; objects: Array; serverlessFunctions: Array; + version: Scalars['String']; +}; + +export type ApplicationVariable = { + __typename?: 'ApplicationVariable'; + description: Scalars['String']; + id: Scalars['UUID']; + isSecret: Scalars['Boolean']; + key: Scalars['String']; + value: Scalars['String']; }; export type ApprovedAccessDomain = { @@ -1841,6 +1852,7 @@ export type Mutation = { updateDatabaseConfigVariable: Scalars['Boolean']; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; + updateOneApplicationVariable: Scalars['Boolean']; updateOneCronTrigger: CronTrigger; updateOneDatabaseEventTrigger: DatabaseEventTrigger; updateOneField: Field; @@ -2584,6 +2596,13 @@ export type MutationUpdateOneAgentArgs = { }; +export type MutationUpdateOneApplicationVariableArgs = { + applicationId: Scalars['UUID']; + key: Scalars['String']; + value: Scalars['String']; +}; + + export type MutationUpdateOneCronTriggerArgs = { input: UpdateCronTriggerInput; }; @@ -4777,19 +4796,28 @@ export type TrackAnalyticsMutationVariables = Exact<{ export type TrackAnalyticsMutation = { __typename?: 'Mutation', trackAnalytics: { __typename?: 'Analytics', success: boolean } }; -export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; +export type UpdateOneApplicationVariableMutationVariables = Exact<{ + key: Scalars['String']; + value: Scalars['String']; + applicationId: Scalars['UUID']; +}>; + + +export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean }; + +export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> }; export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>; -export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description: string }> }; +export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description: string, version: string }> }; export type FindOneApplicationQueryVariables = Exact<{ id: Scalars['UUID']; }>; -export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } }; +export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -6286,6 +6314,14 @@ export const ApplicationFieldsFragmentDoc = gql` id name description + version + applicationVariables { + id + key + value + description + isSecret + } agents { ...AgentFields } @@ -7323,12 +7359,50 @@ export function useTrackAnalyticsMutation(baseOptions?: Apollo.MutationHookOptio export type TrackAnalyticsMutationHookResult = ReturnType; export type TrackAnalyticsMutationResult = Apollo.MutationResult; export type TrackAnalyticsMutationOptions = Apollo.BaseMutationOptions; +export const UpdateOneApplicationVariableDocument = gql` + mutation UpdateOneApplicationVariable($key: String!, $value: String!, $applicationId: UUID!) { + updateOneApplicationVariable( + key: $key + value: $value + applicationId: $applicationId + ) +} + `; +export type UpdateOneApplicationVariableMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateOneApplicationVariableMutation__ + * + * To run a mutation, you first call `useUpdateOneApplicationVariableMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateOneApplicationVariableMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateOneApplicationVariableMutation, { data, loading, error }] = useUpdateOneApplicationVariableMutation({ + * variables: { + * key: // value for 'key' + * value: // value for 'value' + * applicationId: // value for 'applicationId' + * }, + * }); + */ +export function useUpdateOneApplicationVariableMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateOneApplicationVariableDocument, options); + } +export type UpdateOneApplicationVariableMutationHookResult = ReturnType; +export type UpdateOneApplicationVariableMutationResult = Apollo.MutationResult; +export type UpdateOneApplicationVariableMutationOptions = Apollo.BaseMutationOptions; export const FindManyApplicationsDocument = gql` query FindManyApplications { findManyApplications { id name description + version } } `; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index a7638b2253..f4db30588d 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -204,11 +204,22 @@ export type AppTokenEdge = { export type Application = { __typename?: 'Application'; agents: Array; + applicationVariables: Array; description: Scalars['String']; id: Scalars['UUID']; name: Scalars['String']; objects: Array; serverlessFunctions: Array; + version: Scalars['String']; +}; + +export type ApplicationVariable = { + __typename?: 'ApplicationVariable'; + description: Scalars['String']; + id: Scalars['UUID']; + isSecret: Scalars['Boolean']; + key: Scalars['String']; + value: Scalars['String']; }; export type ApprovedAccessDomain = { @@ -1793,6 +1804,7 @@ export type Mutation = { updateDatabaseConfigVariable: Scalars['Boolean']; updateLabPublicFeatureFlag: FeatureFlagDto; updateOneAgent: Agent; + updateOneApplicationVariable: Scalars['Boolean']; updateOneCronTrigger: CronTrigger; updateOneDatabaseEventTrigger: DatabaseEventTrigger; updateOneField: Field; @@ -2500,6 +2512,13 @@ export type MutationUpdateOneAgentArgs = { }; +export type MutationUpdateOneApplicationVariableArgs = { + applicationId: Scalars['UUID']; + key: Scalars['String']; + value: Scalars['String']; +}; + + export type MutationUpdateOneCronTriggerArgs = { input: UpdateCronTriggerInput; }; diff --git a/packages/twenty-front/src/modules/application-variables/graphql/mutations/updateOneApplicationVariable.ts b/packages/twenty-front/src/modules/application-variables/graphql/mutations/updateOneApplicationVariable.ts new file mode 100644 index 0000000000..7139583de1 --- /dev/null +++ b/packages/twenty-front/src/modules/application-variables/graphql/mutations/updateOneApplicationVariable.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_ONE_APPLICATION_VARIABLE = gql` + mutation UpdateOneApplicationVariable( + $key: String! + $value: String! + $applicationId: UUID! + ) { + updateOneApplicationVariable( + key: $key + value: $value + applicationId: $applicationId + ) + } +`; diff --git a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts index 985300a595..4fb0317dbf 100644 --- a/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts +++ b/packages/twenty-front/src/modules/applications/graphql/fragments/applicationFragment.ts @@ -11,6 +11,14 @@ export const APPLICATION_FRAGMENT = gql` id name description + version + applicationVariables { + id + key + value + description + isSecret + } agents { ...AgentFields } diff --git a/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts b/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts index 75ffffc54a..ceb1ba708f 100644 --- a/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts +++ b/packages/twenty-front/src/modules/applications/graphql/queries/findManyApplications.ts @@ -6,6 +6,7 @@ export const FIND_MANY_APPLICATIONS = gql` id name description + version } } `; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx index 07e253dd5c..0e5268270f 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx @@ -1,6 +1,5 @@ import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages'; import { type EditorProps, type Monaco } from '@monaco-editor/react'; -import dotenv from 'dotenv'; import { type editor } from 'monaco-editor'; import { AutoTypings } from 'monaco-editor-auto-typings'; import { useParams } from 'react-router-dom'; @@ -35,7 +34,6 @@ export const SettingsServerlessFunctionCodeEditor = ({ }); const currentFile = files.find((file) => file.path === currentFilePath); - const environmentVariablesFile = files.find((file) => file.path === '.env'); const handleEditorDidMount = async ( editor: editor.IStandaloneCodeEditor, @@ -67,11 +65,10 @@ export const SettingsServerlessFunctionCodeEditor = ({ target: monaco.languages.typescript.ScriptTarget.ESNext, }); - if (isDefined(environmentVariablesFile)) { - const environmentVariables = dotenv.parse( - environmentVariablesFile.content, - ); + // TODO load that with proper env variables + const environmentVariables = {}; + if (isDefined(environmentVariables)) { const environmentDefinition = ` declare namespace NodeJS { interface ProcessEnv { diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx index 690d2f16b8..0c3f612343 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx @@ -25,7 +25,7 @@ export const SettingsServerlessFunctionNewForm = ({ return (
- + { + const { applicationId = '' } = useParams<{ applicationId: string }>(); + return ( +
+ + Environment variables are defined at application level for all functions. + Please check{' '} + + . +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx index 09e245fcdd..4b408d8e0f 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx @@ -44,11 +44,9 @@ export const SettingsServerlessFunctionCodeEditorTab = ({ const HeaderTabList = ( file.path !== '.env') - .map((file) => { - return { id: file.path, title: file.path.split('/').at(-1) || '' }; - })} + tabs={files.map((file) => { + return { id: file.path, title: file.path.split('/').at(-1) || '' }; + })} componentInstanceId={SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID} /> ); diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx index 21dea7b772..c3c0960dff 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx @@ -1,17 +1,13 @@ import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm'; -import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection'; import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; +import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/SettingsServerlessFunctionTabEnvironmentVariablesSection'; export const SettingsServerlessFunctionSettingsTab = ({ formValues, onChange, - onCodeChange, - serverlessFunctionId, }: { formValues: ServerlessFunctionFormValues; - serverlessFunctionId: string; onChange: (key: string) => (value: string) => void; - onCodeChange: (filePath: string, value: string) => void; }) => { return ( <> @@ -20,10 +16,7 @@ export const SettingsServerlessFunctionSettingsTab = ({ onChange={onChange} readonly /> - + ); }; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow.tsx index 8aa0c75388..1a64540435 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow.tsx @@ -1,4 +1,4 @@ -import { type EnvironmentVariable } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection'; +import { type EnvironmentVariable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow'; import { TextInput } from '@/ui/input/components/TextInput'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection.tsx deleted file mode 100644 index 56e8ce955f..0000000000 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { SettingsServerlessFunctionTabEnvironmentVariableTableRow } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow'; -import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; -import { Table } from '@/ui/layout/table/components/Table'; -import { TableBody } from '@/ui/layout/table/components/TableBody'; -import { TableHeader } from '@/ui/layout/table/components/TableHeader'; -import { TableRow } from '@/ui/layout/table/components/TableRow'; -import styled from '@emotion/styled'; -import { useMemo, useState } from 'react'; -import { H2Title, IconPlus, IconSearch } from 'twenty-ui/display'; -import { Button } from 'twenty-ui/input'; -import { Section } from 'twenty-ui/layout'; -import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; -import { v4 } from 'uuid'; -import { serverlessFunctionEnvVarFamilyState } from '@/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState'; -import { useRecoilState } from 'recoil'; - -const StyledSearchInput = styled(SettingsTextInput)` - padding-bottom: ${({ theme }) => theme.spacing(2)}; - width: 100%; -`; - -const StyledButtonContainer = styled.div` - display: flex; - justify-content: flex-end; - padding-top: ${({ theme }) => theme.spacing(2)}; - @media (max-width: ${MOBILE_VIEWPORT}px) { - padding-top: ${({ theme }) => theme.spacing(5)}; - } -`; - -const StyledTableBody = styled(TableBody)` - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; -`; - -const StyledTableRow = styled(TableRow)` - grid-template-columns: 180px auto 32px; -`; - -export type EnvironmentVariable = { id: string; key: string; value: string }; - -export const SettingsServerlessFunctionTabEnvironmentVariablesSection = ({ - onCodeChange, - serverlessFunctionId, -}: { - serverlessFunctionId: string; - onCodeChange: (filePath: string, value: string) => void; -}) => { - const [searchTerm, setSearchTerm] = useState(''); - const [newEnvVarAdded, setNewEnvVarAdded] = useState(false); - const [envVariables, setEnvVariables] = useRecoilState( - serverlessFunctionEnvVarFamilyState(serverlessFunctionId), - ); - - const filteredEnvVariable = useMemo(() => { - return envVariables.filter( - ({ key, value }) => - key.toLowerCase().includes(searchTerm.toLowerCase()) || - value.toLowerCase().includes(searchTerm.toLowerCase()), - ); - }, [envVariables, searchTerm]); - - const getFormattedEnvironmentVariables = ( - newEnvVariables: EnvironmentVariable[], - ) => { - return [...newEnvVariables] - .reverse() - .reduce( - (acc, { key, value }) => - key.length > 0 && value.length > 0 ? `${key}=${value}\n${acc}` : acc, - '', - ); - }; - - const onEnvVarChange = (newEnvVariable: EnvironmentVariable) => { - const newEnvVariables: EnvironmentVariable[] = []; - - for (const envVariable of envVariables) { - if (envVariable.id === newEnvVariable.id) { - newEnvVariables.push(newEnvVariable); - } else if (envVariable.key !== newEnvVariable.key) { - newEnvVariables.push(envVariable); - } - } - - setEnvVariables(newEnvVariables); - onCodeChange('.env', getFormattedEnvironmentVariables(newEnvVariables)); - }; - - return ( -
- - - - - Name - Value - - - {filteredEnvVariable.length > 0 && ( - - {filteredEnvVariable.map((envVariable) => ( - { - const newEnvVariables = envVariables.filter( - ({ id }) => id !== envVariable.id, - ); - setEnvVariables(newEnvVariables); - onCodeChange( - '.env', - getFormattedEnvironmentVariables(newEnvVariables), - ); - }} - /> - ))} - - )} -
- -
- ); -}; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts index 40b421b7b0..aafc76d47c 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts @@ -4,14 +4,11 @@ import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hoo import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode'; import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/code-action/states/serverlessFunctionTestDataFamilyState'; import { type Dispatch, type SetStateAction, useState } from 'react'; -import { useRecoilState, useSetRecoilState } from 'recoil'; +import { useRecoilState } from 'recoil'; import { type FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql'; import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName'; import { type ServerlessFunction } from '~/generated/graphql'; import { type Sources } from '@/serverless-functions/types/sources.type'; -import { serverlessFunctionEnvVarFamilyState } from '@/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState'; -import dotenv from 'dotenv'; -import { v4 } from 'uuid'; export type ServerlessFunctionNewFormValues = { name: string; @@ -44,10 +41,6 @@ export const useServerlessFunctionUpdateFormState = ({ code: { src: { 'index.ts': '' } }, }); - const setEnvVar = useSetRecoilState( - serverlessFunctionEnvVarFamilyState(serverlessFunctionId), - ); - const [serverlessFunctionTestData, setServerlessFunctionTestData] = useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId)); @@ -74,17 +67,6 @@ export const useServerlessFunctionUpdateFormState = ({ ...newState, })); - const environmentVariables = - code?.['.env'] && typeof code?.['.env'] === 'string' - ? dotenv.parse(code['.env']) - : {}; - - const environmentVariablesList = Object.entries( - environmentVariables, - ).map(([key, value]) => ({ id: v4(), key, value })); - - setEnvVar(environmentVariablesList); - if (serverlessFunctionTestData.shouldInitInput) { const sourceCode = data?.getServerlessFunctionSourceCode?.[SOURCE_FOLDER_NAME]?.[ diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState.ts b/packages/twenty-front/src/modules/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState.ts deleted file mode 100644 index 3cf6b8cad4..0000000000 --- a/packages/twenty-front/src/modules/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -import { type EnvironmentVariable } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection'; - -export const serverlessFunctionEnvVarFamilyState = createFamilyState< - EnvironmentVariable[], - string ->({ - key: 'serverlessFunctionEnvVarFamilyState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx index 3b858a4c52..f048cbcce1 100644 --- a/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx +++ b/packages/twenty-front/src/pages/settings/applications/SettingsApplicationDetails.tsx @@ -5,58 +5,60 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa import { useParams } from 'react-router-dom'; import { useFindOneApplicationQuery } from '~/generated-metadata/graphql'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { Section } from 'twenty-ui/layout'; -import { H2Title } from 'twenty-ui/display'; +import { IconInfoCircle, IconSettings, IconBroadcast } from 'twenty-ui/display'; import { SettingsApplicationDetailSkeletonLoader } from '~/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader'; -import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable'; -import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable'; -import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable'; -import { useRecoilValue } from 'recoil'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { TabList } from '@/ui/layout/tab-list/components/TabList'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab'; +import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab'; +import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab'; + +const APPLICATION_DETAIL_ID = 'application-detail-id'; export const SettingsApplicationDetails = () => { const { applicationId = '' } = useParams<{ applicationId: string }>(); - const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const activeTabId = useRecoilComponentValue( + activeTabIdComponentState, + APPLICATION_DETAIL_ID, + ); - const { data, loading } = useFindOneApplicationQuery({ + const { data } = useFindOneApplicationQuery({ variables: { id: applicationId }, skip: !applicationId, }); - if (!isDefined(data?.findOneApplication)) { - return; - } + const application = data?.findOneApplication; - const { - name: applicationName, - serverlessFunctions, - agents, - objects, - } = data.findOneApplication; + const applicationName = application?.name; - const shouldDisplayServerlessFunctions = - !loading && - isDefined(serverlessFunctions) && - serverlessFunctions?.length > 0; - - const shouldDisplayAgents = - !loading && isDefined(agents) && agents.length > 0; - - const shouldDisplayObjects = - !loading && isDefined(objects) && objects.length > 0; - - const objectIds = objects.map((object) => object.id); - - const applicationObjectMetadataItems = shouldDisplayObjects - ? objectMetadataItems.filter((objectMetadataItem) => - objectIds.includes(objectMetadataItem.id), - ) - : []; - - const title = loading + const title = !isDefined(application) ? t`Application details` - : data?.findOneApplication?.name; + : applicationName; + + const tabs = [ + { id: 'about', title: 'About', Icon: IconInfoCircle }, + { id: 'settings', title: 'Settings', Icon: IconSettings }, + { id: 'content', title: 'Content', Icon: IconBroadcast }, + ]; + + const renderActiveTabContent = () => { + switch (activeTabId) { + case 'about': + return ; + case 'settings': + return ( + + ); + case 'content': + return ( + + ); + default: + return <>; + } + }; return ( { children: t`Applications`, href: getSettingsPath(SettingsPath.Applications), }, - { children: `${applicationName}` }, + { children: `${title}` }, ]} > - {loading && } - {shouldDisplayServerlessFunctions && ( -
- - -
- )} - {shouldDisplayAgents && ( -
- - -
- )} - {shouldDisplayObjects && ( -
- - -
+ + {!isDefined(application) ? ( + + ) : ( + renderActiveTabContent() )}
diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader.tsx index 3d70993229..37299b8bad 100644 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader.tsx +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader.tsx @@ -16,20 +16,6 @@ const StyledFormSection = styled.div` gap: ${({ theme }) => theme.spacing(2)}; `; -const StyledIconNameRow = styled.div` - align-items: flex-start; - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledIconContainer = styled.div` - flex-shrink: 0; -`; - -const StyledNameContainer = styled.div` - flex: 1; -`; - export const SettingsApplicationDetailSkeletonLoader = () => { const theme = useTheme(); @@ -41,21 +27,6 @@ export const SettingsApplicationDetailSkeletonLoader = () => { > - - - - - - - - - { height={SKELETON_LOADER_HEIGHT_SIZES.standard.l} width="100%" /> - - diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx new file mode 100644 index 0000000000..f1bfb22740 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx @@ -0,0 +1,53 @@ +import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard'; +import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay'; +import { t } from '@lingui/core/macro'; +import { IconCircleDot, IconStatusChange } from 'twenty-ui/display'; +import type { Application } from '~/generated/graphql'; +import { isDefined } from 'twenty-shared/utils'; + +export const SettingsApplicationVersionContainer = ({ + application, +}: { + application?: Omit & { objects: { id: string }[] }; +}) => { + const loading = !isDefined(application); + + const currentVersion = application?.version; + + // TODO fetch latestVersion of the application + // if published on twenty public application registry + const latestVersion = currentVersion; + + const versionItems = [ + { + Icon: IconCircleDot, + label: t`Current version`, + value: ( + + ), + }, + { + Icon: IconStatusChange, + label: t`Latest version`, + value: ( + + ), + }, + ]; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/hooks/useUpdateOneApplicationVariable.ts b/packages/twenty-front/src/pages/settings/applications/hooks/useUpdateOneApplicationVariable.ts new file mode 100644 index 0000000000..520227c5d9 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/hooks/useUpdateOneApplicationVariable.ts @@ -0,0 +1,29 @@ +import { useMutation } from '@apollo/client'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { UPDATE_ONE_APPLICATION_VARIABLE } from '@/application-variables/graphql/mutations/updateOneApplicationVariable'; +import { + type UpdateOneApplicationVariableMutation, + type UpdateOneApplicationVariableMutationVariables, +} from '~/generated-metadata/graphql'; + +export const useUpdateOneApplicationVariable = () => { + const apolloMetadataClient = useApolloCoreClient(); + const [mutate] = useMutation< + UpdateOneApplicationVariableMutation, + UpdateOneApplicationVariableMutationVariables + >(UPDATE_ONE_APPLICATION_VARIABLE, { client: apolloMetadataClient }); + + const updateOneApplicationVariable = async ({ + key, + value, + applicationId, + }: { + key: string; + value: string; + applicationId: string; + }) => { + return await mutate({ variables: { key, value, applicationId } }); + }; + + return { updateOneApplicationVariable }; +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx new file mode 100644 index 0000000000..0bbb84d7bc --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab.tsx @@ -0,0 +1,52 @@ +import type { Application } from '~/generated/graphql'; +import { isDefined } from 'twenty-shared/utils'; +import { H2Title } from 'twenty-ui/display'; +import { t } from '@lingui/core/macro'; +import { Section } from 'twenty-ui/layout'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { SettingsApplicationVersionContainer } from '~/pages/settings/applications/components/SettingsApplicationVersionContainer'; + +export const SettingsApplicationDetailAboutTab = ({ + application, +}: { + application?: Omit & { objects: { id: string }[] }; +}) => { + if (!isDefined(application)) { + return null; + } + + const { id, name, description } = application; + + return ( + <> +
+ + +
+
+ + +
+
+ + +
+ + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx new file mode 100644 index 0000000000..50a2a4a9c4 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailContentTab.tsx @@ -0,0 +1,75 @@ +import { Section } from 'twenty-ui/layout'; +import { H2Title } from 'twenty-ui/display'; +import { t } from '@lingui/core/macro'; +import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable'; +import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable'; +import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable'; +import { isDefined } from 'twenty-shared/utils'; +import { useRecoilValue } from 'recoil'; +import { type Application } from '~/generated/graphql'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; + +export const SettingsApplicationDetailContentTab = ({ + application, +}: { + application?: Omit & { objects: { id: string }[] }; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + if (!isDefined(application)) { + return null; + } + + const { serverlessFunctions, agents, objects } = application; + + const shouldDisplayServerlessFunctions = + isDefined(serverlessFunctions) && serverlessFunctions?.length > 0; + + const shouldDisplayAgents = isDefined(agents) && agents.length > 0; + + const shouldDisplayObjects = isDefined(objects) && objects.length > 0; + + const applicationObjectMetadataItems = shouldDisplayObjects + ? objectMetadataItems.filter((objectMetadataItem) => + objects.map((object) => object.id).includes(objectMetadataItem.id), + ) + : []; + + return ( + <> + {shouldDisplayServerlessFunctions && ( +
+ + +
+ )} + {shouldDisplayAgents && ( +
+ + +
+ )} + {shouldDisplayObjects && ( +
+ + +
+ )} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable.tsx new file mode 100644 index 0000000000..810a5e2d0d --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable.tsx @@ -0,0 +1,77 @@ +import { Section } from 'twenty-ui/layout'; +import { H2Title, IconSearch } from 'twenty-ui/display'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { useMemo, useState } from 'react'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import styled from '@emotion/styled'; +import { TableBody } from '@/ui/layout/table/components/TableBody'; +import { + type EnvironmentVariable, + SettingsApplicationDetailEnvironmentVariablesTableRow, + StyledApplicationEnvironmentVariableTableRow, +} from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow'; + +const StyledSearchInput = styled(SettingsTextInput)` + padding-bottom: ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledTableBody = styled(TableBody)` + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; +`; + +export const SettingsApplicationDetailEnvironmentVariablesTable = ({ + envVariables, + onUpdate, + readonly, +}: { + envVariables: EnvironmentVariable[]; + onUpdate: (newEnv: Pick) => void; + readonly?: boolean; +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const filteredEnvVariable = useMemo(() => { + return envVariables.filter( + ({ key, value }) => + key.toLowerCase().includes(searchTerm.toLowerCase()) || + value.toLowerCase().includes(searchTerm.toLowerCase()), + ); + }, [envVariables, searchTerm]); + + return ( +
+ + + + + Name + Value + Info + + + {filteredEnvVariable.length > 0 && ( + + {filteredEnvVariable.map((envVariable) => ( + + ))} + + )} +
+
+ ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow.tsx new file mode 100644 index 0000000000..3440cf42c7 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow.tsx @@ -0,0 +1,143 @@ +import { TextInput } from '@/ui/input/components/TextInput'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { + AppTooltip, + IconCheck, + IconDotsVertical, + IconInfoCircle, + IconPencil, + OverflowingTextWithTooltip, + TooltipDelay, +} from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { MenuItem } from 'twenty-ui/navigation'; +import { useTheme } from '@emotion/react'; +import { useLingui } from '@lingui/react/macro'; +import { type ApplicationVariable } from '~/generated/graphql'; + +export const StyledApplicationEnvironmentVariableTableRow = styled(TableRow)` + grid-template-columns: auto 200px 36px 36px; +`; + +export type EnvironmentVariable = ApplicationVariable; + +export const SettingsApplicationDetailEnvironmentVariablesTableRow = ({ + envVariable, + onChange, + readonly, +}: { + envVariable: EnvironmentVariable; + onChange: ( + newEnvVariable: Pick, + ) => void; + readonly?: boolean; +}) => { + const [editedEnvVariable, setEditedEnvVariable] = useState(envVariable); + const [editMode, setEditMode] = useState(false); + const dropDownId = `settings-environment-variable-dropdown-${envVariable.key}`; + const { closeDropdown } = useCloseDropdown(); + const theme = useTheme(); + const { t } = useLingui(); + + const description = + envVariable.description.length > 0 + ? envVariable.description + : t`No description`; + + const InfoTableCell = ( + + + + + ); + + return editMode && !readonly ? ( + + + + + + + setEditedEnvVariable({ ...editedEnvVariable, value: newValue }) + } + placeholder={t`Value`} + fullWidth + /> + + {InfoTableCell} + + { + onChange(editedEnvVariable); + setEditMode(false); + }} + /> + + + ) : ( + setEditMode(true)} + > + + + + + + + {InfoTableCell} + + {!readonly && ( + + } + dropdownComponents={ + + + { + setEditMode(true); + closeDropdown(dropDownId); + }} + /> + + + } + /> + )} + + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx new file mode 100644 index 0000000000..d531c18145 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab.tsx @@ -0,0 +1,33 @@ +import type { Application } from '~/generated/graphql'; +import { isDefined } from 'twenty-shared/utils'; +import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable'; +import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable'; + +export const SettingsApplicationDetailSettingsTab = ({ + application, +}: { + application?: Omit & { objects: { id: string }[] }; +}) => { + const { updateOneApplicationVariable } = useUpdateOneApplicationVariable(); + + if (!isDefined(application)) { + return null; + } + + const envVariables = [...(application.applicationVariables ?? [])].sort( + (a, b) => a.key.localeCompare(b.key), + ); + + return ( + + updateOneApplicationVariable({ + key, + value, + applicationId: application.id, + }) + } + /> + ); +}; diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx index 850b751abe..dea3cc7e3d 100644 --- a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx +++ b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx @@ -9,8 +9,7 @@ import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; -import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { @@ -24,12 +23,15 @@ import { t } from '@lingui/core/macro'; import { useFindOneApplicationQuery } from '~/generated-metadata/graphql'; import { computeNewSources } from '@/serverless-functions/utils/computeNewSources'; import { flattenSources } from '@/serverless-functions/utils/flattenSources'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; const SERVERLESS_FUNCTION_DETAIL_ID = 'serverless-function-detail'; export const SettingsServerlessFunctionDetail = () => { const { serverlessFunctionId = '', applicationId = '' } = useParams(); + const navigate = useNavigate(); + const { data } = useFindOneApplicationQuery({ variables: { id: applicationId }, skip: !applicationId, @@ -37,9 +39,11 @@ export const SettingsServerlessFunctionDetail = () => { const applicationName = data?.findOneApplication?.name; - const [activeTabId, setActiveTabId] = useRecoilComponentState( + const instanceId = `${SERVERLESS_FUNCTION_DETAIL_ID}-${serverlessFunctionId}`; + + const activeTabId = useRecoilComponentValue( activeTabIdComponentState, - SERVERLESS_FUNCTION_DETAIL_ID, + instanceId, ); const { updateOneServerlessFunction } = useUpdateOneServerlessFunction(serverlessFunctionId); @@ -84,8 +88,8 @@ export const SettingsServerlessFunctionDetail = () => { }; const handleTestFunction = async () => { + navigate('#test'); await testServerlessFunction(); - setActiveTabId('test'); }; const tabs = [ @@ -97,17 +101,11 @@ export const SettingsServerlessFunctionDetail = () => { const flattenedCode = flattenSources(formValues.code); - const files = flattenedCode - .map((file) => { - const language = file.path === '.env' ? 'ini' : 'typescript'; - - return { - path: file.path, - language, - content: file.content, - }; - }) - .reverse(); + const files = flattenedCode.map((file) => ({ + path: file.path, + language: 'typescript', + content: file.content, + })); const renderActiveTabContent = () => { switch (activeTabId) { @@ -138,9 +136,7 @@ export const SettingsServerlessFunctionDetail = () => { return ( ); default: @@ -171,10 +167,7 @@ export const SettingsServerlessFunctionDetail = () => { ]} > - + {renderActiveTabContent()} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1760640844181-addApplicationVariableCoreEntity.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760640844181-addApplicationVariableCoreEntity.ts new file mode 100644 index 0000000000..f622bb9034 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760640844181-addApplicationVariableCoreEntity.ts @@ -0,0 +1,23 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddApplicationVariableCoreEntity1760640844181 + implements MigrationInterface +{ + name = 'AddApplicationVariableCoreEntity1760640844181'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."applicationVariable" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" text NOT NULL, "value" text NOT NULL DEFAULT '', "description" text NOT NULL DEFAULT '', "isSecret" boolean NOT NULL DEFAULT false, "applicationId" uuid, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE" UNIQUE ("key", "applicationId"), CONSTRAINT "PK_62f7823eb5f1e416c9d60614dfb" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "core"."applicationVariable" ADD CONSTRAINT "FK_51adb49e7f8df35dd23e01c4830" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."applicationVariable" DROP CONSTRAINT "FK_51adb49e7f8df35dd23e01c4830"`, + ); + await queryRunner.query(`DROP TABLE "core"."applicationVariable"`); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index 23f04cdf0e..bcfe52ad98 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -28,6 +28,7 @@ import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/type import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service'; import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service'; import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type'; +import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service'; @Injectable() export class ApplicationSyncService { @@ -35,6 +36,7 @@ export class ApplicationSyncService { constructor( private readonly applicationService: ApplicationService, + private readonly applicationVariableService: ApplicationVariableService, private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService, private readonly objectMetadataServiceV2: ObjectMetadataServiceV2, private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service, @@ -106,7 +108,7 @@ export class ApplicationSyncService { workspaceId, ); - return await this.applicationService.create({ + const application = await this.applicationService.create({ universalIdentifier: manifest.universalIdentifier, name: manifest.name, description: manifest.description, @@ -115,6 +117,13 @@ export class ApplicationSyncService { serverlessFunctionLayerId: serverlessFunctionLayer.id, workspaceId, }); + + await this.applicationVariableService.upsertManyApplicationVariables({ + env: manifest.env, + applicationId: application.id, + }); + + return application; } await this.serverlessFunctionLayerService.update( @@ -131,6 +140,11 @@ export class ApplicationSyncService { version: manifest.version, }); + await this.applicationVariableService.upsertManyApplicationVariables({ + env: manifest.env, + applicationId: application.id, + }); + return application; } diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index 34c1f5b834..5e13f92477 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -18,6 +18,7 @@ import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; +import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity'; @Entity({ name: 'application', schema: 'core' }) @Index('IDX_APPLICATION_WORKSPACE_ID', ['workspaceId']) @@ -86,6 +87,15 @@ export class ApplicationEntity { }) objects: Relation; + @OneToMany( + () => ApplicationVariable, + (applicationVariable) => applicationVariable.application, + { + onDelete: 'CASCADE', + }, + ) + applicationVariables: Relation; + @ManyToOne(() => Workspace, { onDelete: 'CASCADE', }) diff --git a/packages/twenty-server/src/engine/core-modules/application/application.module.ts b/packages/twenty-server/src/engine/core-modules/application/application.module.ts index 88b36bba76..d12ffb3b0f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.module.ts @@ -16,6 +16,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module'; import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; +import { ApplicationVariableModule } from 'src/engine/core-modules/applicationVariable/application-variable.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless ObjectMetadataModule, DataSourceModule, AgentModule, + ApplicationVariableModule, ServerlessFunctionLayerModule, ServerlessFunctionModule, DatabaseEventTriggerModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index 3be4c28e1f..28316d6a1b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -25,7 +25,12 @@ export class ApplicationService { ): Promise { return this.applicationRepository.find({ where: { workspaceId }, - relations: ['serverlessFunctions', 'agents', 'objects'], + relations: [ + 'serverlessFunctions', + 'agents', + 'objects', + 'applicationVariables', + ], }); } @@ -35,7 +40,12 @@ export class ApplicationService { ): Promise { const application = await this.applicationRepository.findOne({ where: { workspaceId, id: applicationId }, - relations: ['serverlessFunctions', 'agents', 'objects'], + relations: [ + 'serverlessFunctions', + 'agents', + 'objects', + 'applicationVariables', + ], }); if (!isDefined(application)) { diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts index 6bbe4cd4fe..2f569c8587 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts @@ -6,6 +6,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/ import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto'; import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; +import { ApplicationVariableDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto'; @ObjectType('Application') export class ApplicationDTO { @@ -22,6 +23,10 @@ export class ApplicationDTO { @Field() description: string; + @IsString() + @Field() + version: string; + @Field(() => [AgentDTO]) agents: AgentDTO[]; @@ -30,4 +35,7 @@ export class ApplicationDTO { @Field(() => [ObjectMetadataDTO]) objects: ObjectMetadataDTO[]; + + @Field(() => [ApplicationVariableDTO]) + applicationVariables: ApplicationVariableDTO[]; } diff --git a/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts b/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts index 430e01446b..447ab06b04 100644 --- a/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts +++ b/packages/twenty-server/src/engine/core-modules/application/types/application.types.ts @@ -11,6 +11,15 @@ export type PackageJson = { npm: string; yarn: string; }; + env: Record< + string, + { + key: string; + value?: string; + description?: string; + isSecret: boolean; + } + >; icon?: string; version: string; dependencies?: object; diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable-exception-filter.ts new file mode 100644 index 0000000000..46caa0532b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable-exception-filter.ts @@ -0,0 +1,21 @@ +import { Catch, ExceptionFilter } from '@nestjs/common'; + +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + ApplicationVariableException, + ApplicationVariableExceptionCode, +} from 'src/engine/core-modules/applicationVariable/application-variable.exception'; +import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +@Catch(ApplicationVariableException) +export class ApplicationVariableExceptionFilter implements ExceptionFilter { + catch(exception: ApplicationVariableException) { + switch (exception.code) { + case ApplicationVariableExceptionCode.APPLICATION_VARIABLE_NOT_FOUND: + throw new NotFoundError(exception); + default: + assertUnreachable(exception.code); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.entity.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.entity.ts new file mode 100644 index 0000000000..f01a0ed386 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.entity.ts @@ -0,0 +1,65 @@ +import { ObjectType } from '@nestjs/graphql'; + +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; + +@Entity({ + name: 'applicationVariable', + schema: 'core', +}) +@ObjectType() +@Unique('IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE', [ + 'key', + 'applicationId', +]) +export class ApplicationVariable { + @IDField(() => UUIDScalarType) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false, type: 'text' }) + key: string; + + @Column({ nullable: false, type: 'text', default: '' }) + value: string; + + @Column({ nullable: false, type: 'text', default: '' }) + description: string; + + @Column({ nullable: false, type: 'boolean', default: false }) + isSecret: boolean; + + @Column({ nullable: true, type: 'uuid' }) + applicationId?: string; + + @ManyToOne( + () => ApplicationEntity, + (application) => application.applicationVariables, + { + onDelete: 'CASCADE', + nullable: true, + }, + ) + @JoinColumn({ name: 'applicationId' }) + application: Relation | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.exception.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.exception.ts new file mode 100644 index 0000000000..56acf52402 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.exception.ts @@ -0,0 +1,7 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class ApplicationVariableException extends CustomException {} + +export enum ApplicationVariableExceptionCode { + APPLICATION_VARIABLE_NOT_FOUND = 'APPLICATION_VARIABLE_NOT_FOUND', +} diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.module.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.module.ts new file mode 100644 index 0000000000..abdf78695e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity'; +import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service'; +import { ApplicationVariableResolver } from 'src/engine/core-modules/applicationVariable/application-variable.resolver'; + +@Module({ + imports: [NestjsQueryTypeOrmModule.forFeature([ApplicationVariable])], + providers: [ApplicationVariableService, ApplicationVariableResolver], + exports: [ApplicationVariableService], +}) +export class ApplicationVariableModule {} diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.resolver.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.resolver.ts new file mode 100644 index 0000000000..8f9a761bbf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.resolver.ts @@ -0,0 +1,25 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service'; +import { UpdateApplicationVariableInput } from 'src/engine/core-modules/applicationVariable/dtos/update-application-variable.input'; +import { ApplicationVariableExceptionFilter } from 'src/engine/core-modules/applicationVariable/application-variable-exception-filter'; + +@UseGuards(WorkspaceAuthGuard) +@Resolver() +@UseFilters(ApplicationVariableExceptionFilter) +export class ApplicationVariableResolver { + constructor( + private readonly applicationVariableService: ApplicationVariableService, + ) {} + + @Mutation(() => Boolean) + async updateOneApplicationVariable( + @Args() { key, value, applicationId }: UpdateApplicationVariableInput, + ) { + await this.applicationVariableService.update({ key, value, applicationId }); + + return true; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts new file mode 100644 index 0000000000..1db1c6c343 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/application-variable.service.ts @@ -0,0 +1,58 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { In, Not, Repository } from 'typeorm'; + +import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity'; + +export class ApplicationVariableService { + constructor( + @InjectRepository(ApplicationVariable) + private readonly applicationVariableRepository: Repository, + ) {} + + async update({ + key, + value, + applicationId, + }: Pick & { applicationId: string }) { + await this.applicationVariableRepository.update( + { key, applicationId }, + { + value, + }, + ); + } + + async upsertManyApplicationVariables({ + env, + applicationId, + }: { + env: Record< + string, + { + value?: string; + description?: string; + isSecret: boolean; + } + >; + applicationId: string; + }) { + for (const [key, { value, description, isSecret }] of Object.entries(env)) { + await this.applicationVariableRepository.upsert( + { + key, + value, + description, + isSecret, + applicationId, + }, + { conflictPaths: ['key', 'applicationId'] }, + ); + } + + await this.applicationVariableRepository.delete({ + applicationId, + key: Not(In(Object.keys(env))), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/application-variable.dto.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/application-variable.dto.ts new file mode 100644 index 0000000000..b6c3906730 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/application-variable.dto.ts @@ -0,0 +1,28 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IsBoolean, IsString } from 'class-validator'; +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('ApplicationVariable') +export class ApplicationVariableDTO { + @IDField(() => UUIDScalarType) + id: string; + + @IsString() + @Field() + key: string; + + @IsString() + @Field() + value: string; + + @IsString() + @Field() + description: string; + + @IsBoolean() + @Field() + isSecret: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/update-application-variable.input.ts b/packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/update-application-variable.input.ts new file mode 100644 index 0000000000..be3edaa0c2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/applicationVariable/dtos/update-application-variable.input.ts @@ -0,0 +1,15 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ArgsType() +export class UpdateApplicationVariableInput { + @Field(() => String, { nullable: false }) + key: string; + + @Field(() => String, { nullable: false }) + value: string; + + @Field(() => UUIDScalarType, { nullable: false }) + applicationId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs b/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs index 127b65cd1b..0291b67c15 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/constants/executor/index.mjs @@ -6,17 +6,20 @@ export const handler = async (event) => { const mainPath = `/tmp/${randomId}.mjs`; + const oldProcessEnv = { ...process.env }; + try { - const { code, params } = event; + const { code, params, env } = event; await fs.writeFile(mainPath, code, 'utf8'); - process.env = {}; + process.env = { ...process.env, ...(env ?? {}) }; const mainFile = await import(mainPath); return await mainFile.main(params); } finally { await fs.rm(mainPath, { force: true }); + process.env = oldProcessEnv; } }; diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts index 7bdcd1a234..173b0bf0e1 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/lambda.driver.ts @@ -46,6 +46,7 @@ import { } from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory'; import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error'; +import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var'; const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60; const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h @@ -338,6 +339,7 @@ export class LambdaDriver implements ServerlessDriver { const executorPayload = { params: payload, code: compiledCode, + env: buildEnvVar(serverlessFunction), }; const params: InvokeCommandInput = { diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts index b1f10d8af1..82dfd75a35 100644 --- a/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/local.driver.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'fs'; import { join } from 'path'; +import { spawn } from 'node:child_process'; import { type ServerlessDriver, @@ -16,6 +17,7 @@ import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serve import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/serverless/drivers/utils/lambda-build-directory-manager'; import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory'; import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error'; +import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var'; export interface LocalDriverOptions { fileStorageService: FileStorageService; @@ -162,30 +164,53 @@ export class LocalDriver implements ServerlessDriver { }); try { - const mainFile = await import(builtBundleFilePath); - - const result = await this.executeWithTimeout( - () => mainFile.main(payload), - serverlessFunction.timeoutSeconds * 1_000, + const runnerPath = await this.writeBootstrapRunner( + sourceTemporaryDir, + builtBundleFilePath, ); + const { ok, result, error, stack, stdout, stderr } = + await this.runChildWithEnv({ + runnerPath, + env: buildEnvVar(serverlessFunction), + payload, + timeoutMs: serverlessFunction.timeoutSeconds * 1_000, + }); + + if (stdout) + logs += + stdout + .split('\n') + .filter(Boolean) + .map((l) => `${new Date().toISOString()} INFO ${l}`) + .join('\n') + '\n'; + if (stderr) + logs += + stderr + .split('\n') + .filter(Boolean) + .map((l) => `${new Date().toISOString()} ERROR ${l}`) + .join('\n') + '\n'; + const duration = Date.now() - startTime; - return { - data: result, - logs, - duration, - status: ServerlessFunctionExecutionStatus.SUCCESS, - }; - } catch (error) { + if (ok) { + return { + data: (result ?? null) as object | null, + logs, + duration, + status: ServerlessFunctionExecutionStatus.SUCCESS, + }; + } + return { data: null, logs, - duration: Date.now() - startTime, + duration, error: { errorType: 'UnhandledError', - errorMessage: error.message || 'Unknown error', - stackTrace: error.stack ? error.stack.split('\n') : [], + errorMessage: error || 'Unknown error', + stackTrace: stack ? String(stack).split('\n') : [], }, status: ServerlessFunctionExecutionStatus.ERROR, }; @@ -196,4 +221,143 @@ export class LocalDriver implements ServerlessDriver { await lambdaBuildDirectoryManager.clean(); } } + + async writeBootstrapRunner(dir: string, builtFileAbsPath: string) { + const runnerPath = join(dir, '__runner.cjs'); + const code = ` + // Auto-generated. Do not edit. + const { pathToFileURL } = require('node:url'); + + (async () => { + try { + const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)}); + const mod = await import(builtUrl.href); + if (typeof mod.main !== 'function') { + throw new Error('Export "main" not found in serverless bundle'); + } + + let payload = undefined; + if (process.send) { + process.on('message', async (msg) => { + if (!msg || msg.type !== 'run') return; + try { + const out = await mod.main(msg.payload); + process.send && process.send({ ok: true, result: out }); + process.exit(0); + } catch (err) { + process.send && process.send({ ok: false, error: String(err), stack: err?.stack }); + process.exit(1); + } + }); + } else { + // Fallback: read payload from argv[2] (JSON) and print to stdout + const json = process.argv[2]; + payload = json ? JSON.parse(json) : undefined; + const out = await mod.main(payload); + console.log(JSON.stringify({ ok: true, result: out })); + process.exit(0); + } + } catch (err) { + const msg = String(err); + if (process.send) { + process.send({ ok: false, error: msg, stack: err?.stack }); + } else { + console.error(msg); + } + process.exit(1); + } + })(); + `; + + await fs.writeFile(runnerPath, code, 'utf8'); + + return runnerPath; + } + + runChildWithEnv(options: { + runnerPath: string; + env: Record; + payload: unknown; + timeoutMs: number; + }) { + const { runnerPath, env, payload, timeoutMs } = options; + + return new Promise<{ + ok: boolean; + result?: unknown; + error?: string; + stack?: string; + stdout: string; + stderr: string; + }>((resolve, _) => { + const child = spawn(process.execPath, [runnerPath], { + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + child.stdout?.on('data', (d) => (stdout += String(d))); + child.stderr?.on('data', (d) => (stderr += String(d))); + + child.on( + 'message', + ( + msg: + | { + ok: true; + result?: unknown; + stdout?: string; + stderr?: string; + } + | { + ok: false; + error: string; + stack?: string; + stdout?: string; + stderr?: string; + }, + ) => { + if (settled) return; + settled = true; + resolve({ ...msg, stdout, stderr }); + }, + ); + + child.on('exit', (code) => { + if (settled) return; + settled = true; + if (code === 0) { + // Fallback path if no IPC (shouldn’t happen with our stdio) + resolve({ ok: true, stdout, stderr }); + } else { + resolve({ + ok: false, + error: `Exited with code ${code}`, + stdout, + stderr, + }); + } + }); + + const t = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + resolve({ + ok: false, + error: `Timed out after ${timeoutMs}ms`, + stdout, + stderr, + }); + }, timeoutMs); + + // Kick it off + child.send?.({ type: 'run', payload }); + + child.on('close', () => clearTimeout(t)); + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-env-var.ts b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-env-var.ts new file mode 100644 index 0000000000..09bab79dbd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/serverless/drivers/utils/build-env-var.ts @@ -0,0 +1,12 @@ +import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; + +export const buildEnvVar = (serverlessFunction: ServerlessFunctionEntity) => { + return (serverlessFunction.application?.applicationVariables ?? []).reduce( + (acc, v) => { + acc[v.key] = String(v.value ?? ''); + + return acc; + }, + {} as Record, + ); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts index c93f1469fe..e1d22cbc86 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.service.ts @@ -96,7 +96,10 @@ export class ServerlessFunctionService { id, workspaceId, }, - relations: ['serverlessFunctionLayer'], + relations: [ + 'serverlessFunctionLayer', + 'application.applicationVariables', + ], }); const resultServerlessFunction = await this.serverlessService.execute( diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts index 126a045c0c..e74972ef21 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/types/serverless-function-code.type.ts @@ -4,5 +4,4 @@ export type ServerlessFunctionCode = { src: { 'index.ts': string; } & Sources; - '.env'?: string; }; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 6bcf07a06d..71bbfdb5f3 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -44,6 +44,7 @@ export { IconBrandLinkedin, IconBrandX, IconBriefcase, + IconBroadcast, IconBrowserMaximize, IconBuildingSkyscraper, IconCalendar, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index f1c40ef73c..8f26e536c1 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -107,6 +107,7 @@ export { IconBrandLinkedin, IconBrandX, IconBriefcase, + IconBroadcast, IconBrowserMaximize, IconBuildingSkyscraper, IconCalendar,