& {
+ header: React.ReactNode;
+ onChange?: (value: string) => void;
+};
+
+export const CodeEditor = ({
+ value = DEFAULT_CODE,
+ onChange,
+ language = 'typescript',
+ height = 500,
+ options = undefined,
+ header,
+}: CodeEditorProps) => {
+ const theme = useTheme();
+ const handleEditorDidMount = (
+ editor: editor.IStandaloneCodeEditor,
+ monaco: Monaco,
+ ) => {
+ monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
+ monaco.editor.setTheme('codeEditorTheme');
+ };
+ useEffect(() => {
+ const style = document.createElement('style');
+ style.innerHTML = `
+ .monaco-editor .margin .line-numbers {
+ font-weight: bold;
+ }
+ `;
+ document.head.appendChild(style);
+ return () => {
+ document.head.removeChild(style);
+ };
+ }, []);
+ return (
+
+ {header}
+ value && onChange?.(value)}
+ options={{
+ ...options,
+ overviewRulerLanes: 0,
+ scrollbar: {
+ vertical: 'hidden',
+ horizontal: 'hidden',
+ },
+ minimap: {
+ enabled: false,
+ },
+ }}
+ />
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/input/code-editor/components/CodeEditorHeader.tsx b/packages/twenty-front/src/modules/ui/input/code-editor/components/CodeEditorHeader.tsx
new file mode 100644
index 000000000000..0ac5904784d7
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/input/code-editor/components/CodeEditorHeader.tsx
@@ -0,0 +1,51 @@
+import styled from '@emotion/styled';
+
+const StyledEditorHeader = styled.div`
+ align-items: center;
+ background-color: ${({ theme }) => theme.background.transparent.lighter};
+ color: ${({ theme }) => theme.font.color.tertiary};
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+ display: flex;
+ height: ${({ theme }) => theme.spacing(10)};
+ padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
+ border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
+ justify-content: space-between;
+`;
+
+const StyledElementContainer = styled.div`
+ align-content: flex-end;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+export type CoreEditorHeaderProps = {
+ title?: string;
+ leftNodes?: React.ReactNode[];
+ rightNodes?: React.ReactNode[];
+};
+
+export const CoreEditorHeader = ({
+ title,
+ leftNodes,
+ rightNodes,
+}: CoreEditorHeaderProps) => {
+ return (
+
+
+ {leftNodes &&
+ leftNodes.map((leftButton, index) => {
+ return {leftButton}
;
+ })}
+ {title}
+
+
+ {rightNodes &&
+ rightNodes.map((rightButton, index) => {
+ return {rightButton}
;
+ })}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/input/code-editor/theme/CodeEditorTheme.ts b/packages/twenty-front/src/modules/ui/input/code-editor/theme/CodeEditorTheme.ts
new file mode 100644
index 000000000000..bb4bd909209a
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/input/code-editor/theme/CodeEditorTheme.ts
@@ -0,0 +1,33 @@
+import { editor } from 'monaco-editor';
+import { ThemeType } from 'twenty-ui';
+
+export const codeEditorTheme = (theme: ThemeType) => {
+ return {
+ base: 'vs' as editor.BuiltinTheme,
+ inherit: true,
+ rules: [
+ {
+ token: '',
+ foreground: theme.code.text.gray,
+ fontStyle: 'bold',
+ },
+ { token: 'keyword', foreground: theme.code.text.sky },
+ {
+ token: 'delimiter',
+ foreground: theme.code.text.gray,
+ },
+ { token: 'string', foreground: theme.code.text.pink },
+ {
+ token: 'comment',
+ foreground: theme.code.text.orange,
+ },
+ ],
+ colors: {
+ 'editor.background': theme.background.secondary,
+ 'editorCursor.foreground': theme.font.color.primary,
+ 'editorLineNumber.foreground': theme.font.color.extraLight,
+ 'editorLineNumber.activeForeground': theme.font.color.light,
+ 'editor.lineHighlightBackground': theme.background.tertiary,
+ },
+ };
+};
diff --git a/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx b/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx
index 4d116a173489..aee5943e6e16 100644
--- a/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx
+++ b/packages/twenty-front/src/modules/ui/input/components/TextArea.tsx
@@ -29,7 +29,6 @@ const StyledTextArea = styled(TextareaAutosize)`
line-height: 16px;
overflow: auto;
padding: ${({ theme }) => theme.spacing(2)};
- padding-top: ${({ theme }) => theme.spacing(3)};
resize: none;
width: 100%;
diff --git a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/Background.ts b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/Background.ts
index 7498e1442163..cbe6ee21af7a 100644
--- a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/Background.ts
+++ b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/Background.ts
@@ -8,6 +8,7 @@ export const BACKGROUND: Record = {
emptyTimeline: '/images/placeholders/background/empty_timeline_bg.png',
loadingMessages: '/images/placeholders/background/loading_messages_bg.png',
loadingAccounts: '/images/placeholders/background/loading_accounts_bg.png',
+ emptyFunctions: '/images/placeholders/background/empty_functions_bg.png',
emptyInbox: '/images/placeholders/background/empty_inbox_bg.png',
error404: '/images/placeholders/background/404_bg.png',
error500: '/images/placeholders/background/500_bg.png',
diff --git a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkBackground.ts b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkBackground.ts
index 6fc5e701f60c..c609745b5d93 100644
--- a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkBackground.ts
+++ b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkBackground.ts
@@ -11,4 +11,5 @@ export const DARK_BACKGROUND: Record = {
error500: '/images/placeholders/dark-background/500_bg.png',
loadingMessages: '/images/placeholders/background/loading_messages_bg.png',
loadingAccounts: '/images/placeholders/background/loading_accounts_bg.png',
+ emptyFunctions: '/images/placeholders/dark-background/empty_functions_bg.png',
};
diff --git a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkMovingImage.ts b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkMovingImage.ts
index d8173f6f5e16..f39ed95a6d51 100644
--- a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkMovingImage.ts
+++ b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/DarkMovingImage.ts
@@ -11,4 +11,5 @@ export const DARK_MOVING_IMAGE: Record = {
error500: '/images/placeholders/dark-moving-image/500.png',
loadingMessages: '/images/placeholders/moving-image/loading_messages.png',
loadingAccounts: '/images/placeholders/moving-image/loading_accounts.png',
+ emptyFunctions: '/images/placeholders/dark-moving-image/empty_functions.png',
};
diff --git a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/MovingImage.ts b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/MovingImage.ts
index d08eac3e8260..6b23d3f97b12 100644
--- a/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/MovingImage.ts
+++ b/packages/twenty-front/src/modules/ui/layout/animated-placeholder/constants/MovingImage.ts
@@ -8,6 +8,7 @@ export const MOVING_IMAGE: Record = {
emptyTimeline: '/images/placeholders/moving-image/empty_timeline.png',
loadingMessages: '/images/placeholders/moving-image/loading_messages.png',
loadingAccounts: '/images/placeholders/moving-image/loading_accounts.png',
+ emptyFunctions: '/images/placeholders/moving-image/empty_functions.png',
emptyInbox: '/images/placeholders/moving-image/empty_inbox.png',
error404: '/images/placeholders/moving-image/404.png',
error500: '/images/placeholders/moving-image/500.png',
diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx
index 0cd5b4bccc11..120d5e720263 100644
--- a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx
@@ -22,6 +22,7 @@ type TabListProps = {
tabListId: string;
tabs: SingleTabProps[];
loading?: boolean;
+ className?: string;
};
const StyledContainer = styled.div`
@@ -34,7 +35,12 @@ const StyledContainer = styled.div`
user-select: none;
`;
-export const TabList = ({ tabs, tabListId, loading }: TabListProps) => {
+export const TabList = ({
+ tabs,
+ tabListId,
+ loading,
+ className,
+}: TabListProps) => {
const initialActiveTabId = tabs.find((tab) => !tab.hide)?.id || '';
const { activeTabIdState, setActiveTabId } = useTabList(tabListId);
@@ -48,7 +54,7 @@ export const TabList = ({ tabs, tabListId, loading }: TabListProps) => {
return (
-
+
{tabs
.filter((tab) => !tab.hide)
.map((tab) => (
diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
index 4334a6b9699c..bbcca44a7c16 100644
--- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
+++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
@@ -5,5 +5,6 @@ export type FeatureFlagKey =
| 'IS_AIRTABLE_INTEGRATION_ENABLED'
| 'IS_POSTGRESQL_INTEGRATION_ENABLED'
| 'IS_STRIPE_INTEGRATION_ENABLED'
+ | 'IS_FUNCTION_SETTINGS_ENABLED'
| 'IS_COPILOT_ENABLED'
| 'IS_CRM_MIGRATION_ENABLED';
diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx
index a744030a69dd..7199d871a13d 100644
--- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx
+++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx
@@ -18,6 +18,7 @@ import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
+import { Key } from 'ts-key-enum';
export const SettingsDevelopersApiKeysNew = () => {
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
@@ -85,7 +86,7 @@ export const SettingsDevelopersApiKeysNew = () => {
placeholder="E.g. backoffice integration"
value={formValues.name}
onKeyDown={(e) => {
- if (e.key === 'Enter') {
+ if (e.key === Key.Enter) {
handleSave();
}
}}
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/ResetServerlessFunctionStatesEffect.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/ResetServerlessFunctionStatesEffect.tsx
new file mode 100644
index 000000000000..f158f69cb69c
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/ResetServerlessFunctionStatesEffect.tsx
@@ -0,0 +1,20 @@
+import { useResetRecoilState } from 'recoil';
+import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
+import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
+import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
+
+export const ResetServerlessFunctionStatesEffect = () => {
+ const resetSettingsServerlessFunctionInput = useResetRecoilState(
+ settingsServerlessFunctionInputState,
+ );
+ const resetSettingsServerlessFunctionOutput = useResetRecoilState(
+ settingsServerlessFunctionOutputState,
+ );
+ const resetSettingsServerlessFunctionCodeEditorOutputParamsState =
+ useResetRecoilState(settingsServerlessFunctionCodeEditorOutputParamsState);
+
+ resetSettingsServerlessFunctionInput();
+ resetSettingsServerlessFunctionOutput();
+ resetSettingsServerlessFunctionCodeEditorOutputParamsState();
+ return <>>;
+};
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx
new file mode 100644
index 000000000000..b20dbd389d24
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetail.tsx
@@ -0,0 +1,156 @@
+import { useParams } from 'react-router-dom';
+import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui';
+import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
+import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
+import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
+import { Section } from '@/ui/layout/section/components/Section';
+import { TabList } from '@/ui/layout/tab/components/TabList';
+import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
+import { useRecoilValue, useSetRecoilState } from 'recoil';
+import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab';
+import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
+import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
+import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab';
+import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
+import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
+import { useDebouncedCallback } from 'use-debounce';
+import { SettingsServerlessFunctionTestTabEffect } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTabEffect';
+import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
+import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
+
+const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
+
+export const SettingsServerlessFunctionDetail = () => {
+ const { serverlessFunctionId = '' } = useParams();
+ const { enqueueSnackBar } = useSnackBar();
+ const { activeTabIdState, setActiveTabId } = useTabList(
+ TAB_LIST_COMPONENT_ID,
+ );
+ const activeTabId = useRecoilValue(activeTabIdState);
+ const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
+ const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
+ const [formValues, setFormValues] =
+ useServerlessFunctionUpdateFormState(serverlessFunctionId);
+ const setSettingsServerlessFunctionOutput = useSetRecoilState(
+ settingsServerlessFunctionOutputState,
+ );
+ const settingsServerlessFunctionInput = useRecoilValue(
+ settingsServerlessFunctionInputState,
+ );
+
+ const save = async () => {
+ try {
+ await updateOneServerlessFunction({
+ id: serverlessFunctionId,
+ name: formValues.name,
+ description: formValues.description,
+ code: formValues.code,
+ });
+ } catch (err) {
+ enqueueSnackBar(
+ (err as Error)?.message || 'An error occurred while updating function',
+ {
+ variant: SnackBarVariant.Error,
+ },
+ );
+ }
+ };
+
+ const handleSave = useDebouncedCallback(save, 500);
+
+ const onChange = (key: string) => {
+ return async (value: string | undefined) => {
+ setFormValues((prevState) => ({
+ ...prevState,
+ [key]: value,
+ }));
+ await handleSave();
+ };
+ };
+
+ const handleExecute = async () => {
+ await handleSave();
+ try {
+ const result = await executeOneServerlessFunction(
+ serverlessFunctionId,
+ JSON.parse(settingsServerlessFunctionInput),
+ );
+ setSettingsServerlessFunctionOutput(
+ JSON.stringify(
+ result?.data?.executeOneServerlessFunction?.result,
+ null,
+ 4,
+ ),
+ );
+ } catch (err) {
+ enqueueSnackBar(
+ (err as Error)?.message || 'An error occurred while executing function',
+ {
+ variant: SnackBarVariant.Error,
+ },
+ );
+ setSettingsServerlessFunctionOutput(JSON.stringify(err, null, 4));
+ }
+ setActiveTabId('test');
+ };
+
+ const tabs = [
+ { id: 'editor', title: 'Editor', Icon: IconCode },
+ { id: 'test', title: 'Test', Icon: IconTestPipe },
+ { id: 'settings', title: 'Settings', Icon: IconSettings },
+ ];
+
+ const renderActiveTabContent = () => {
+ switch (activeTabId) {
+ case 'editor':
+ return (
+
+ );
+ case 'test':
+ return (
+ <>
+
+
+ >
+ );
+ case 'settings':
+ return (
+
+ );
+ default:
+ return <>>;
+ }
+ };
+
+ return (
+ formValues.name && (
+
+
+
+
+
+
+ {renderActiveTabContent()}
+
+
+ )
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper.tsx
new file mode 100644
index 000000000000..8671851e53e4
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper.tsx
@@ -0,0 +1,11 @@
+import { ResetServerlessFunctionStatesEffect } from '~/pages/settings/serverless-functions/ResetServerlessFunctionStatesEffect';
+import { SettingsServerlessFunctionDetail } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail';
+
+export const SettingsServerlessFunctionDetailWrapper = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctions.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctions.tsx
new file mode 100644
index 000000000000..d186ffdec76a
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctions.tsx
@@ -0,0 +1,36 @@
+import { IconPlus, IconSettings } from 'twenty-ui';
+import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
+import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
+import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
+import { Section } from '@/ui/layout/section/components/Section';
+import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
+import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
+import { SettingsPath } from '@/types/SettingsPath';
+import { Button } from '@/ui/input/button/components/Button';
+import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
+
+export const SettingsServerlessFunctions = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionsNew.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionsNew.tsx
new file mode 100644
index 000000000000..2f5dbce31bf7
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/SettingsServerlessFunctionsNew.tsx
@@ -0,0 +1,81 @@
+import { IconSettings } from 'twenty-ui';
+import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
+import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
+import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
+import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
+import { useNavigate } from 'react-router-dom';
+
+import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
+import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
+import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
+import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
+import { isDefined } from '~/utils/isDefined';
+import { useState } from 'react';
+import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
+import { SettingsPath } from '@/types/SettingsPath';
+
+export const SettingsServerlessFunctionsNew = () => {
+ const navigate = useNavigate();
+ const [formValues, setFormValues] = useState(
+ {
+ name: '',
+ description: '',
+ },
+ );
+
+ const { createOneServerlessFunction } = useCreateOneServerlessFunction();
+ const handleSave = async () => {
+ const newServerlessFunction = await createOneServerlessFunction({
+ name: formValues.name,
+ description: formValues.description,
+ code: DEFAULT_CODE,
+ });
+
+ if (!isDefined(newServerlessFunction?.data)) {
+ return;
+ }
+ navigate(
+ getSettingsPagePath(SettingsPath.ServerlessFunctions, {
+ id: newServerlessFunction.data.createOneServerlessFunction.id,
+ }),
+ );
+ };
+
+ const onChange = (key: string) => {
+ return (value: string | undefined) => {
+ setFormValues((prevState) => ({
+ ...prevState,
+ [key]: value,
+ }));
+ };
+ };
+
+ const canSave = !!formValues.name && createOneServerlessFunction;
+
+ return (
+
+
+
+
+ {
+ navigate('/settings/functions');
+ }}
+ onSave={handleSave}
+ />
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctionDetail.stories.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctionDetail.stories.tsx
new file mode 100644
index 000000000000..11257930e439
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctionDetail.stories.tsx
@@ -0,0 +1,69 @@
+import { SettingsServerlessFunctionDetail } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail';
+import { graphqlMocks } from '~/testing/graphqlMocks';
+import { Meta, StoryObj } from '@storybook/react';
+import {
+ PageDecorator,
+ PageDecoratorArgs,
+} from '~/testing/decorators/PageDecorator';
+import { graphql, http, HttpResponse } from 'msw';
+import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
+import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
+import { within } from '@storybook/test';
+import { sleep } from '~/utils/sleep';
+
+const SOURCE_CODE_FULL_PATH =
+ 'serverless-function/20202020-1c25-4d02-bf25-6aeccf7ea419/adb4bd21-7670-4c81-9f74-1fc196fe87ea/source.ts';
+
+const meta: Meta = {
+ title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctionDetail',
+ component: SettingsServerlessFunctionDetail,
+ decorators: [PageDecorator],
+ args: {
+ routePath: '/settings/function/',
+ routeParams: {
+ ':serverlessFunctionId': 'adb4bd21-7670-4c81-9f74-1fc196fe87ea',
+ },
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ ...graphqlMocks.handlers,
+ graphql.query('GetOneServerlessFunction', () => {
+ return HttpResponse.json({
+ data: {
+ serverlessFunction: {
+ __typename: 'ServerlessFunction',
+ id: 'adb4bd21-7670-4c81-9f74-1fc196fe87ea',
+ name: 'Serverless Function Name',
+ description: '',
+ syncStatus: 'READY',
+ runtime: 'nodejs18.x',
+ sourceCodeFullPath: SOURCE_CODE_FULL_PATH,
+ sourceCodeHash: '42d2734b3dc8a7b45a16803ed7f417bc',
+ updatedAt: '2024-02-24T10:23:10.673Z',
+ createdAt: '2024-02-24T10:23:10.673Z',
+ },
+ },
+ });
+ }),
+ http.get(
+ getImageAbsoluteURIOrBase64(SOURCE_CODE_FULL_PATH) || '',
+ () => {
+ return HttpResponse.text(DEFAULT_CODE);
+ },
+ ),
+ ],
+ },
+ },
+};
+export default meta;
+
+export type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await sleep(100);
+ await canvas.findByText('Code your function');
+ },
+};
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctions.stories.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctions.stories.tsx
new file mode 100644
index 000000000000..eb1474a8b126
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctions.stories.tsx
@@ -0,0 +1,32 @@
+import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions';
+import { graphqlMocks } from '~/testing/graphqlMocks';
+import { Meta, StoryObj } from '@storybook/react';
+import {
+ PageDecorator,
+ PageDecoratorArgs,
+} from '~/testing/decorators/PageDecorator';
+import { sleep } from '~/utils/sleep';
+import { within } from '@storybook/test';
+
+const meta: Meta = {
+ title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctions',
+ component: SettingsServerlessFunctions,
+ decorators: [PageDecorator],
+ args: { routePath: '/settings/functions' },
+ parameters: {
+ msw: graphqlMocks,
+ },
+};
+export default meta;
+
+export type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await sleep(100);
+
+ await canvas.findByText('Functions');
+ await canvas.findByText('Add your first Function');
+ },
+};
diff --git a/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctionsNew.stories.tsx b/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctionsNew.stories.tsx
new file mode 100644
index 000000000000..2f2ac5c1a698
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/serverless-functions/__stories__/SettingsServerlessFunctionsNew.stories.tsx
@@ -0,0 +1,37 @@
+import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew';
+import { graphqlMocks } from '~/testing/graphqlMocks';
+import { Meta, StoryObj } from '@storybook/react';
+import {
+ PageDecorator,
+ PageDecoratorArgs,
+} from '~/testing/decorators/PageDecorator';
+import { userEvent, within } from '@storybook/test';
+import { sleep } from '~/utils/sleep';
+
+const meta: Meta = {
+ title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctionsNew',
+ component: SettingsServerlessFunctionsNew,
+ decorators: [PageDecorator],
+ args: { routePath: '/settings/functions/new' },
+ parameters: {
+ msw: graphqlMocks,
+ },
+};
+export default meta;
+
+export type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await sleep(100);
+ await canvas.findByText('Functions');
+ await canvas.findByText('New');
+
+ const input = await canvas.findByPlaceholderText('Name');
+ await userEvent.type(input, 'Function Name');
+ const saveButton = await canvas.findByText('Save');
+
+ await userEvent.click(saveButton);
+ },
+};
diff --git a/packages/twenty-front/src/utils/array/__tests__/sortByAscString.test.ts b/packages/twenty-front/src/utils/array/__tests__/sortByAscString.test.ts
new file mode 100644
index 000000000000..c75772578646
--- /dev/null
+++ b/packages/twenty-front/src/utils/array/__tests__/sortByAscString.test.ts
@@ -0,0 +1,9 @@
+import { sortByAscString } from '~/utils/array/sortByAscString';
+
+describe('sortByAscString', () => {
+ test('should sort properly', () => {
+ expect(sortByAscString('a', 'b')).toEqual(-1);
+ expect(sortByAscString('b', 'a')).toEqual(1);
+ expect(sortByAscString('a', 'a')).toEqual(0);
+ });
+});
diff --git a/packages/twenty-front/src/utils/file/__tests__/getFileAbsoluteURI.test.ts b/packages/twenty-front/src/utils/file/__tests__/getFileAbsoluteURI.test.ts
new file mode 100644
index 000000000000..01a75cecaa94
--- /dev/null
+++ b/packages/twenty-front/src/utils/file/__tests__/getFileAbsoluteURI.test.ts
@@ -0,0 +1,10 @@
+import { getFileAbsoluteURI } from '../getFileAbsoluteURI';
+import { REACT_APP_SERVER_BASE_URL } from '~/config';
+
+describe('getFileAbsoluteURI', () => {
+ test('should return absolute uri', () => {
+ expect(getFileAbsoluteURI('foo')).toEqual(
+ `${REACT_APP_SERVER_BASE_URL}/files/foo`,
+ );
+ });
+});
diff --git a/packages/twenty-front/src/utils/file/getFileAbsoluteURI.ts b/packages/twenty-front/src/utils/file/getFileAbsoluteURI.ts
new file mode 100644
index 000000000000..f3777e38a92c
--- /dev/null
+++ b/packages/twenty-front/src/utils/file/getFileAbsoluteURI.ts
@@ -0,0 +1,5 @@
+import { REACT_APP_SERVER_BASE_URL } from '~/config';
+
+export const getFileAbsoluteURI = (fileUrl?: string) => {
+ return `${REACT_APP_SERVER_BASE_URL}/files/${fileUrl}`;
+};
diff --git a/packages/twenty-front/src/utils/title-utils.ts b/packages/twenty-front/src/utils/title-utils.ts
index 9d604c5eeb46..74213f0083da 100644
--- a/packages/twenty-front/src/utils/title-utils.ts
+++ b/packages/twenty-front/src/utils/title-utils.ts
@@ -10,6 +10,7 @@ export enum SettingsPageTitles {
Members = 'Members - Settings',
Developers = 'Developers - Settings',
Integration = 'Integrations - Settings',
+ ServerlessFunctions = 'Functions - Settings',
General = 'General - Settings',
Default = 'Settings',
}
@@ -21,6 +22,7 @@ enum SettingsPathPrefixes {
Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`,
Members = `${AppBasePath.Settings}/${SettingsPath.WorkspaceMembersPage}`,
Developers = `${AppBasePath.Settings}/${SettingsPath.Developers}`,
+ ServerlessFunctions = `${AppBasePath.Settings}/${SettingsPath.ServerlessFunctions}`,
Integration = `${AppBasePath.Settings}/${SettingsPath.Integrations}`,
General = `${AppBasePath.Settings}/${SettingsPath.Workspace}`,
}
@@ -63,6 +65,8 @@ export const getPageTitleFromPath = (pathname: string): string => {
return SettingsPageTitles.Objects;
case SettingsPathPrefixes.Developers:
return SettingsPageTitles.Developers;
+ case SettingsPathPrefixes.ServerlessFunctions:
+ return SettingsPageTitles.ServerlessFunctions;
case SettingsPathPrefixes.Integration:
return SettingsPageTitles.Integration;
case SettingsPathPrefixes.General:
diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json
index 5cfb3d6b98cf..19dbeabcff6c 100644
--- a/packages/twenty-server/package.json
+++ b/packages/twenty-server/package.json
@@ -17,6 +17,7 @@
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga-nestjs-npm-2.1.0-cb509e6047.patch",
"@langchain/mistralai": "^0.0.24",
"@langchain/openai": "^0.1.3",
+ "@monaco-editor/react": "^4.6.0",
"@nestjs/cache-manager": "^2.2.1",
"@nestjs/devtools-integration": "^0.1.6",
"@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch",
@@ -34,6 +35,7 @@
"lodash.omitby": "^4.6.0",
"lodash.uniq": "^4.5.0",
"lodash.uniqby": "^4.7.0",
+ "monaco-editor": "^0.50.0",
"passport": "^0.7.0",
"psl": "^1.9.0",
"tsconfig-paths": "^4.2.0",
diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts
index c4e42f81bc44..2e4b47c9f457 100644
--- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts
+++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts
@@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
+ {
+ key: FeatureFlagKeys.IsFunctionSettingsEnabled,
+ workspaceId: workspaceId,
+ value: true,
+ },
{
key: FeatureFlagKeys.IsWorkflowEnabled,
workspaceId: workspaceId,
diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1721309629608-addRuntimeColumnToServerlessFunction.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1721309629608-addRuntimeColumnToServerlessFunction.ts
new file mode 100644
index 000000000000..c1ac579fcdd1
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1721309629608-addRuntimeColumnToServerlessFunction.ts
@@ -0,0 +1,25 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddRuntimeColumnToServerlessFunction1721309629608
+ implements MigrationInterface
+{
+ name = 'AddRuntimeColumnToServerlessFunction1721309629608';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "metadata"."serverlessFunction" ADD "runtime" character varying NOT NULL DEFAULT 'nodejs18.x'`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "metadata"."serverlessFunction" ADD "description" character varying`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "metadata"."serverlessFunction" ADD "sourceCodeFullPath" character varying NOT NULL`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "runtime"`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts
index 9ee46f1278f4..8d79be8f02f6 100644
--- a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.entity.ts
@@ -25,6 +25,7 @@ export enum FeatureFlagKeys {
IsMessagingAliasFetchingEnabled = 'IS_MESSAGING_ALIAS_FETCHING_ENABLED',
IsGoogleCalendarSyncV2Enabled = 'IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED',
IsFreeAccessEnabled = 'IS_FREE_ACCESS_ENABLED',
+ IsFunctionSettingsEnabled = 'IS_FUNCTION_SETTINGS_ENABLED',
IsWorkflowEnabled = 'IS_WORKFLOW_ENABLED',
}
diff --git a/packages/twenty-server/src/engine/core-modules/file/file.utils.ts b/packages/twenty-server/src/engine/core-modules/file/file.utils.ts
index 8868e4bc220d..ef05b9419bd5 100644
--- a/packages/twenty-server/src/engine/core-modules/file/file.utils.ts
+++ b/packages/twenty-server/src/engine/core-modules/file/file.utils.ts
@@ -23,7 +23,11 @@ export const checkFilePath = (filePath: string): string => {
throw new BadRequestException(`Folder ${folder} is not allowed`);
}
- if (size && !settings.storage.imageCropSizes[folder]?.includes(size)) {
+ if (
+ folder !== kebabCase(FileFolder.ServerlessFunction) &&
+ size &&
+ !settings.storage.imageCropSizes[folder]?.includes(size)
+ ) {
throw new BadRequestException(`Size ${size} is not allowed`);
}
diff --git a/packages/twenty-server/src/engine/integrations/file-storage/drivers/interfaces/storage-driver.interface.ts b/packages/twenty-server/src/engine/integrations/file-storage/drivers/interfaces/storage-driver.interface.ts
index 6f1280cd7466..fb46892e7604 100644
--- a/packages/twenty-server/src/engine/integrations/file-storage/drivers/interfaces/storage-driver.interface.ts
+++ b/packages/twenty-server/src/engine/integrations/file-storage/drivers/interfaces/storage-driver.interface.ts
@@ -1,6 +1,7 @@
import { Readable } from 'stream';
export interface StorageDriver {
+ delete(params: { folderPath: string; filename?: string }): Promise;
read(params: { folderPath: string; filename: string }): Promise;
write(params: {
file: Buffer | Uint8Array | string;
diff --git a/packages/twenty-server/src/engine/integrations/file-storage/drivers/local.driver.ts b/packages/twenty-server/src/engine/integrations/file-storage/drivers/local.driver.ts
index 0f28eadd4e0e..e61a9338aef9 100644
--- a/packages/twenty-server/src/engine/integrations/file-storage/drivers/local.driver.ts
+++ b/packages/twenty-server/src/engine/integrations/file-storage/drivers/local.driver.ts
@@ -42,6 +42,19 @@ export class LocalDriver implements StorageDriver {
await fs.writeFile(filePath, params.file);
}
+ async delete(params: {
+ folderPath: string;
+ filename?: string;
+ }): Promise {
+ const filePath = join(
+ `${this.options.storagePath}/`,
+ params.folderPath,
+ params.filename || '',
+ );
+
+ await fs.rm(filePath, { recursive: true });
+ }
+
async read(params: {
folderPath: string;
filename: string;
diff --git a/packages/twenty-server/src/engine/integrations/file-storage/drivers/s3.driver.ts b/packages/twenty-server/src/engine/integrations/file-storage/drivers/s3.driver.ts
index a8bfc8d1e447..9d0627071a8a 100644
--- a/packages/twenty-server/src/engine/integrations/file-storage/drivers/s3.driver.ts
+++ b/packages/twenty-server/src/engine/integrations/file-storage/drivers/s3.driver.ts
@@ -2,8 +2,11 @@ import { Readable } from 'stream';
import {
CreateBucketCommandInput,
+ DeleteObjectCommand,
+ DeleteObjectsCommand,
GetObjectCommand,
HeadBucketCommandInput,
+ ListObjectsV2Command,
NotFound,
PutObjectCommand,
S3,
@@ -53,6 +56,57 @@ export class S3Driver implements StorageDriver {
await this.s3Client.send(command);
}
+ private async emptyS3Directory(folderPath) {
+ const listParams = {
+ Bucket: this.bucketName,
+ Prefix: folderPath,
+ };
+
+ const listObjectsCommand = new ListObjectsV2Command(listParams);
+ const listedObjects = await this.s3Client.send(listObjectsCommand);
+
+ if (listedObjects.Contents?.length === 0) return;
+
+ const deleteParams = {
+ Bucket: this.bucketName,
+ Delete: {
+ Objects: listedObjects.Contents?.map(({ Key }) => {
+ return { Key };
+ }),
+ },
+ };
+
+ const deleteObjectCommand = new DeleteObjectsCommand(deleteParams);
+
+ await this.s3Client.send(deleteObjectCommand);
+
+ if (listedObjects.IsTruncated) {
+ await this.emptyS3Directory(folderPath);
+ }
+ }
+
+ async delete(params: {
+ folderPath: string;
+ filename?: string;
+ }): Promise {
+ if (params.filename) {
+ const deleteCommand = new DeleteObjectCommand({
+ Key: `${params.folderPath}/${params.filename}`,
+ Bucket: this.bucketName,
+ });
+
+ await this.s3Client.send(deleteCommand);
+ } else {
+ await this.emptyS3Directory(params.folderPath);
+ const deleteEmptyFolderCommand = new DeleteObjectCommand({
+ Key: `${params.folderPath}`,
+ Bucket: this.bucketName,
+ });
+
+ await this.s3Client.send(deleteEmptyFolderCommand);
+ }
+ }
+
async read(params: {
folderPath: string;
filename: string;
diff --git a/packages/twenty-server/src/engine/integrations/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/integrations/file-storage/file-storage.service.ts
index 9e4a93e88777..063170614055 100644
--- a/packages/twenty-server/src/engine/integrations/file-storage/file-storage.service.ts
+++ b/packages/twenty-server/src/engine/integrations/file-storage/file-storage.service.ts
@@ -10,6 +10,10 @@ import { StorageDriver } from './drivers/interfaces/storage-driver.interface';
export class FileStorageService implements StorageDriver {
constructor(@Inject(STORAGE_DRIVER) private driver: StorageDriver) {}
+ delete(params: { folderPath: string; filename?: string }): Promise {
+ return this.driver.delete(params);
+ }
+
write(params: {
file: string | Buffer | Uint8Array;
name: string;
diff --git a/packages/twenty-server/src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface.ts b/packages/twenty-server/src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface.ts
index 4d1489bbe088..c15eb07ca45a 100644
--- a/packages/twenty-server/src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface.ts
+++ b/packages/twenty-server/src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface.ts
@@ -1,6 +1,7 @@
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
export interface ServerlessDriver {
+ delete(serverlessFunction: ServerlessFunctionEntity): Promise;
build(serverlessFunction: ServerlessFunctionEntity): Promise;
execute(
serverlessFunction: ServerlessFunctionEntity,
diff --git a/packages/twenty-server/src/engine/integrations/serverless/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/integrations/serverless/drivers/lambda.driver.ts
index d115cd9a42b0..241744b9212c 100644
--- a/packages/twenty-server/src/engine/integrations/serverless/drivers/lambda.driver.ts
+++ b/packages/twenty-server/src/engine/integrations/serverless/drivers/lambda.driver.ts
@@ -5,8 +5,12 @@ import {
Lambda,
LambdaClientConfig,
InvokeCommand,
+ GetFunctionCommand,
+ UpdateFunctionCodeCommand,
+ DeleteFunctionCommand,
} from '@aws-sdk/client-lambda';
import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand';
+import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand';
import { ServerlessDriver } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
@@ -42,6 +46,18 @@ export class LambdaDriver
this.buildDirectoryManagerService = options.buildDirectoryManagerService;
}
+ async delete(serverlessFunction: ServerlessFunctionEntity) {
+ try {
+ const deleteFunctionCommand = new DeleteFunctionCommand({
+ FunctionName: serverlessFunction.id,
+ });
+
+ await this.lambdaClient.send(deleteFunctionCommand);
+ } catch {
+ return;
+ }
+ }
+
async build(serverlessFunction: ServerlessFunctionEntity) {
const javascriptCode = await this.getCompiledCode(
serverlessFunction,
@@ -59,21 +75,44 @@ export class LambdaDriver
await createZipFile(sourceTemporaryDir, lambdaZipPath);
- const params: CreateFunctionCommandInput = {
- Code: {
+ let existingFunction = true;
+
+ try {
+ const getFunctionCommand = new GetFunctionCommand({
+ FunctionName: serverlessFunction.id,
+ });
+
+ await this.lambdaClient.send(getFunctionCommand);
+ } catch {
+ existingFunction = false;
+ }
+
+ if (!existingFunction) {
+ const params: CreateFunctionCommandInput = {
+ Code: {
+ ZipFile: await fs.promises.readFile(lambdaZipPath),
+ },
+ FunctionName: serverlessFunction.id,
+ Handler: lambdaHandler,
+ Role: this.lambdaRole,
+ Runtime: serverlessFunction.runtime,
+ Description: 'Lambda function to run user script',
+ Timeout: 900,
+ };
+
+ const command = new CreateFunctionCommand(params);
+
+ await this.lambdaClient.send(command);
+ } else {
+ const params: UpdateFunctionCodeCommandInput = {
ZipFile: await fs.promises.readFile(lambdaZipPath),
- },
- FunctionName: serverlessFunction.id,
- Handler: lambdaHandler,
- Role: this.lambdaRole,
- Runtime: 'nodejs18.x',
- Description: 'Lambda function to run user script',
- Timeout: 900,
- };
+ FunctionName: serverlessFunction.id,
+ };
- const command = new CreateFunctionCommand(params);
+ const command = new UpdateFunctionCodeCommand(params);
- await this.lambdaClient.send(command);
+ await this.lambdaClient.send(command);
+ }
await this.buildDirectoryManagerService.clean();
}
diff --git a/packages/twenty-server/src/engine/integrations/serverless/drivers/local.driver.ts b/packages/twenty-server/src/engine/integrations/serverless/drivers/local.driver.ts
index 15e5701e5615..5f661b4938c8 100644
--- a/packages/twenty-server/src/engine/integrations/serverless/drivers/local.driver.ts
+++ b/packages/twenty-server/src/engine/integrations/serverless/drivers/local.driver.ts
@@ -28,6 +28,12 @@ export class LocalDriver
this.fileStorageService = options.fileStorageService;
}
+ async delete(serverlessFunction: ServerlessFunctionEntity) {
+ await this.fileStorageService.delete({
+ folderPath: this.getFolderPath(serverlessFunction),
+ });
+ }
+
async build(serverlessFunction: ServerlessFunctionEntity) {
const javascriptCode = await this.getCompiledCode(
serverlessFunction,
@@ -57,8 +63,16 @@ export class LocalDriver
const modifiedContent = `
process.on('message', async (message) => {
const { event, context } = message;
- const result = await handler(event, context);
- process.send(result);
+ try {
+ const result = await handler(event, context);
+ process.send(result);
+ } catch (error) {
+ process.send({
+ errorType: error.name,
+ errorMessage: error.message,
+ stackTrace: error.stack.split('\\n').filter((line) => line.trim() !== ''),
+ });
+ }
});
${fileContent}
@@ -67,7 +81,7 @@ export class LocalDriver
await fs.writeFile(tmpFilePath, modifiedContent);
return await new Promise((resolve, reject) => {
- const child = fork(tmpFilePath);
+ const child = fork(tmpFilePath, { silent: true });
child.on('message', (message: object) => {
resolve(message);
@@ -75,6 +89,32 @@ export class LocalDriver
fs.unlink(tmpFilePath);
});
+ child.stderr?.on('data', (data) => {
+ const stackTrace = data
+ .toString()
+ .split('\n')
+ .filter((line) => line.trim() !== '');
+ const errorTrace = stackTrace.filter((line) =>
+ line.includes('Error: '),
+ )?.[0];
+
+ let errorType = 'Unknown';
+ let errorMessage = '';
+
+ if (errorTrace) {
+ errorType = errorTrace.split(':')[0];
+ errorMessage = errorTrace.split(': ')[1];
+ }
+
+ resolve({
+ errorType,
+ errorMessage,
+ stackTrace: stackTrace,
+ });
+ child.kill();
+ fs.unlink(tmpFilePath);
+ });
+
child.on('error', (error) => {
reject(error);
child.kill();
diff --git a/packages/twenty-server/src/engine/integrations/serverless/serverless.service.ts b/packages/twenty-server/src/engine/integrations/serverless/serverless.service.ts
index 72f003913a86..372b2cb3b554 100644
--- a/packages/twenty-server/src/engine/integrations/serverless/serverless.service.ts
+++ b/packages/twenty-server/src/engine/integrations/serverless/serverless.service.ts
@@ -9,6 +9,10 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless
export class ServerlessService implements ServerlessDriver {
constructor(@Inject(SERVERLESS_DRIVER) private driver: ServerlessDriver) {}
+ async delete(serverlessFunction: ServerlessFunctionEntity): Promise {
+ return this.driver.delete(serverlessFunction);
+ }
+
async build(serverlessFunction: ServerlessFunctionEntity): Promise {
return this.driver.build(serverlessFunction);
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input.ts
new file mode 100644
index 000000000000..0e9f2885d676
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input.ts
@@ -0,0 +1,16 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
+
+@InputType()
+export class CreateServerlessFunctionFromFileInput {
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ name: string;
+
+ @IsString()
+ @IsOptional()
+ @Field({ nullable: true })
+ description?: string;
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input.ts
new file mode 100644
index 000000000000..63431ad881f7
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input.ts
@@ -0,0 +1,13 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import { IsNotEmpty, IsString } from 'class-validator';
+
+import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
+
+@InputType()
+export class CreateServerlessFunctionInput extends CreateServerlessFunctionFromFileInput {
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ code: string;
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input.ts
new file mode 100644
index 000000000000..27cfe36fea34
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input.ts
@@ -0,0 +1,9 @@
+import { ID, InputType } from '@nestjs/graphql';
+
+import { IDField } from '@ptc-org/nestjs-query-graphql';
+
+@InputType()
+export class DeleteServerlessFunctionInput {
+ @IDField(() => ID, { description: 'The id of the function.' })
+ id!: string;
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input.ts
index 836748314006..08071b04f5e3 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input.ts
@@ -1,14 +1,18 @@
import { ArgsType, Field } from '@nestjs/graphql';
-import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
+import { IsNotEmpty, IsObject, IsOptional, IsUUID } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+
@ArgsType()
export class ExecuteServerlessFunctionInput {
- @Field({ description: 'Name of the serverless function to execute' })
+ @Field(() => UUIDScalarType, {
+ description: 'Id of the serverless function to execute',
+ })
@IsNotEmpty()
- @IsString()
- name: string;
+ @IsUUID()
+ id: string;
@Field(() => graphqlTypeJson, {
description: 'Payload in JSON format',
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result-d-t.o.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto.ts
similarity index 86%
rename from packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result-d-t.o.ts
rename to packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto.ts
index 4560c4371a3b..06caa4042fed 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result-d-t.o.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto.ts
@@ -4,7 +4,7 @@ import { IsObject } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
@ObjectType('ServerlessFunctionExecutionResult')
-export class ServerlessFunctionExecutionResultDTO {
+export class ServerlessFunctionExecutionResultDto {
@IsObject()
@Field(() => graphqlTypeJson, {
description: 'Execution result in JSON format',
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts
index cabebe263b88..7a145c631b17 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts
@@ -26,7 +26,7 @@ registerEnumType(ServerlessFunctionSyncStatus, {
description: 'SyncStatus of the serverlessFunction',
});
-@ObjectType('serverlessFunction')
+@ObjectType('ServerlessFunction')
@Authorize({
authorize: (context: any) => ({
workspaceId: { eq: context?.req?.user?.workspace?.id },
@@ -47,11 +47,25 @@ export class ServerlessFunctionDto {
@Field()
name: string;
+ @IsString()
+ @Field()
+ description: string;
+
@IsString()
@IsNotEmpty()
@Field()
sourceCodeHash: string;
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ sourceCodeFullPath: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ runtime: string;
+
@IsEnum(ServerlessFunctionSyncStatus)
@IsNotEmpty()
@Field(() => ServerlessFunctionSyncStatus)
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input.ts
new file mode 100644
index 000000000000..2955137196ee
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input.ts
@@ -0,0 +1,29 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
+
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+
+@InputType()
+export class UpdateServerlessFunctionInput {
+ @Field(() => UUIDScalarType, {
+ description: 'Id of the serverless function to execute',
+ })
+ @IsNotEmpty()
+ @IsUUID()
+ id: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ name: string;
+
+ @IsString()
+ @Field({ nullable: true })
+ description?: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ code: string;
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts
index 5ed76fefd44e..a5893abe2aee 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.entity.ts
@@ -12,6 +12,10 @@ export enum ServerlessFunctionSyncStatus {
READY = 'READY',
}
+export enum ServerlessFunctionRuntime {
+ NODE18 = 'nodejs18.x',
+}
+
@Entity('serverlessFunction')
@Unique('IndexOnNameAndWorkspaceIdUnique', ['name', 'workspaceId'])
export class ServerlessFunctionEntity {
@@ -21,9 +25,18 @@ export class ServerlessFunctionEntity {
@Column({ nullable: false })
name: string;
+ @Column({ nullable: true })
+ description: string;
+
@Column({ nullable: false })
sourceCodeHash: string;
+ @Column({ nullable: false })
+ sourceCodeFullPath: string;
+
+ @Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 })
+ runtime: ServerlessFunctionRuntime;
+
@Column({
nullable: false,
default: ServerlessFunctionSyncStatus.NOT_READY,
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts
index bc4266f08ebb..6f0430431a3a 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.exception.ts
@@ -9,6 +9,7 @@ export class ServerlessFunctionException extends CustomException {
export enum ServerlessFunctionExceptionCode {
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
+ FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST',
SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY',
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts
index 94ca8bedea00..25a4714103ed 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.module.ts
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
import {
NestjsQueryGraphQLModule,
@@ -14,6 +15,7 @@ import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverle
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
+import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@Module({
imports: [
@@ -24,6 +26,7 @@ import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-
[ServerlessFunctionEntity],
'metadata',
),
+ TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
],
services: [ServerlessFunctionService],
resolvers: [
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts
index 286ffc19254e..76a4a4762780 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts
@@ -1,7 +1,9 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
+import { InjectRepository } from '@nestjs/typeorm';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
+import { Repository } from 'typeorm';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@@ -9,45 +11,136 @@ import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serv
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
-import { ServerlessFunctionExecutionResultDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result-d-t.o';
+import { ServerlessFunctionExecutionResultDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
+import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
+import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
+import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
+import { DeleteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input';
+import {
+ FeatureFlagEntity,
+ FeatureFlagKeys,
+} from 'src/engine/core-modules/feature-flag/feature-flag.entity';
+import {
+ ServerlessFunctionException,
+ ServerlessFunctionExceptionCode,
+} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
@UseGuards(JwtAuthGuard)
@Resolver()
export class ServerlessFunctionResolver {
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
+ @InjectRepository(FeatureFlagEntity, 'core')
+ private readonly featureFlagRepository: Repository,
) {}
+ async checkFeatureFlag(workspaceId: string) {
+ const isFunctionSettingsEnabled =
+ await this.featureFlagRepository.findOneBy({
+ workspaceId,
+ key: FeatureFlagKeys.IsFunctionSettingsEnabled,
+ value: true,
+ });
+
+ if (!isFunctionSettingsEnabled) {
+ throw new ServerlessFunctionException(
+ `IS_FUNCTION_SETTINGS_ENABLED feature flag is not set to true for this workspace`,
+ ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
+ );
+ }
+ }
+
+ @Mutation(() => ServerlessFunctionDto)
+ async deleteOneServerlessFunction(
+ @Args('input') input: DeleteServerlessFunctionInput,
+ @AuthWorkspace() { id: workspaceId }: Workspace,
+ ) {
+ try {
+ await this.checkFeatureFlag(workspaceId);
+
+ return await this.serverlessFunctionService.deleteOneServerlessFunction(
+ input.id,
+ workspaceId,
+ );
+ } catch (error) {
+ serverlessFunctionGraphQLApiExceptionHandler(error);
+ }
+ }
+
+ @Mutation(() => ServerlessFunctionDto)
+ async updateOneServerlessFunction(
+ @Args('input')
+ input: UpdateServerlessFunctionInput,
+ @AuthWorkspace() { id: workspaceId }: Workspace,
+ ) {
+ try {
+ await this.checkFeatureFlag(workspaceId);
+
+ return await this.serverlessFunctionService.updateOneServerlessFunction(
+ input,
+ workspaceId,
+ );
+ } catch (error) {
+ serverlessFunctionGraphQLApiExceptionHandler(error);
+ }
+ }
+
@Mutation(() => ServerlessFunctionDto)
async createOneServerlessFunction(
+ @Args('input')
+ input: CreateServerlessFunctionInput,
+ @AuthWorkspace() { id: workspaceId }: Workspace,
+ ) {
+ try {
+ await this.checkFeatureFlag(workspaceId);
+
+ return await this.serverlessFunctionService.createOneServerlessFunction(
+ {
+ name: input.name,
+ description: input.description,
+ },
+ input.code,
+ workspaceId,
+ );
+ } catch (error) {
+ serverlessFunctionGraphQLApiExceptionHandler(error);
+ }
+ }
+
+ @Mutation(() => ServerlessFunctionDto)
+ async createOneServerlessFunctionFromFile(
@Args({ name: 'file', type: () => GraphQLUpload })
file: FileUpload,
- @Args('name', { type: () => String }) name: string,
+ @Args('input')
+ input: CreateServerlessFunctionFromFileInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
- return await this.serverlessFunctionService.createOne(
- name,
- workspaceId,
+ await this.checkFeatureFlag(workspaceId);
+
+ return await this.serverlessFunctionService.createOneServerlessFunction(
+ input,
file,
+ workspaceId,
);
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
}
- @Mutation(() => ServerlessFunctionExecutionResultDTO)
+ @Mutation(() => ServerlessFunctionExecutionResultDto)
async executeOneServerlessFunction(
@Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
- const { name, payload } = executeServerlessFunctionInput;
+ await this.checkFeatureFlag(workspaceId);
+ const { id, payload } = executeServerlessFunctionInput;
return {
result: await this.serverlessFunctionService.executeOne(
- name,
+ id,
workspaceId,
payload,
),
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 5d9c22a912c8..01881cfb1afd 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
@@ -5,6 +5,8 @@ import { join } from 'path';
import { FileUpload } from 'graphql-upload';
import { Repository } from 'typeorm';
+import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
+import { v4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
@@ -21,24 +23,28 @@ import { readFileContent } from 'src/engine/integrations/file-storage/utils/read
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { SOURCE_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/source-file-name';
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
+import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
+import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
@Injectable()
-export class ServerlessFunctionService {
+export class ServerlessFunctionService extends TypeOrmQueryService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly serverlessService: ServerlessService,
@InjectRepository(ServerlessFunctionEntity, 'metadata')
private readonly serverlessFunctionRepository: Repository,
- ) {}
+ ) {
+ super(serverlessFunctionRepository);
+ }
async executeOne(
- name: string,
+ id: string,
workspaceId: string,
payload: object | undefined = undefined,
) {
const functionToExecute = await this.serverlessFunctionRepository.findOne({
where: {
- name,
+ id,
workspaceId,
},
});
@@ -62,14 +68,82 @@ export class ServerlessFunctionService {
return this.serverlessService.execute(functionToExecute, payload);
}
- async createOne(
- name: string,
+ async deleteOneServerlessFunction(id: string, workspaceId: string) {
+ const existingServerlessFunction =
+ await this.serverlessFunctionRepository.findOne({
+ where: { id, workspaceId },
+ });
+
+ if (!existingServerlessFunction) {
+ throw new ServerlessFunctionException(
+ `Function does not exist`,
+ ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
+ );
+ }
+
+ await super.deleteOne(id);
+
+ await this.serverlessService.delete(existingServerlessFunction);
+
+ return existingServerlessFunction;
+ }
+
+ async updateOneServerlessFunction(
+ serverlessFunctionInput: UpdateServerlessFunctionInput,
workspaceId: string,
- { createReadStream, mimetype }: FileUpload,
) {
const existingServerlessFunction =
await this.serverlessFunctionRepository.findOne({
- where: { name, workspaceId },
+ where: { id: serverlessFunctionInput.id, workspaceId },
+ });
+
+ if (!existingServerlessFunction) {
+ throw new ServerlessFunctionException(
+ `Function does not exist`,
+ ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
+ );
+ }
+
+ const codeHasChanged =
+ serverlessFunctionCreateHash(serverlessFunctionInput.code) !==
+ existingServerlessFunction.sourceCodeHash;
+
+ await super.updateOne(existingServerlessFunction.id, {
+ name: serverlessFunctionInput.name,
+ description: serverlessFunctionInput.description,
+ sourceCodeHash: serverlessFunctionCreateHash(
+ serverlessFunctionInput.code,
+ ),
+ });
+
+ if (codeHasChanged) {
+ const fileFolder = join(
+ FileFolder.ServerlessFunction,
+ workspaceId,
+ existingServerlessFunction.id,
+ );
+
+ await this.fileStorageService.write({
+ file: serverlessFunctionInput.code,
+ name: SOURCE_FILE_NAME,
+ mimeType: undefined,
+ folder: fileFolder,
+ });
+
+ await this.serverlessService.build(existingServerlessFunction);
+ }
+
+ return await this.findById(existingServerlessFunction.id);
+ }
+
+ async createOneServerlessFunction(
+ serverlessFunctionInput: CreateServerlessFunctionFromFileInput,
+ code: FileUpload | string,
+ workspaceId: string,
+ ) {
+ const existingServerlessFunction =
+ await this.serverlessFunctionRepository.findOne({
+ where: { name: serverlessFunctionInput.name, workspaceId },
});
if (existingServerlessFunction) {
@@ -79,34 +153,44 @@ export class ServerlessFunctionService {
);
}
- const typescriptCode = await readFileContent(createReadStream());
+ let typescriptCode: string;
- const serverlessFunction = await this.serverlessFunctionRepository.save({
- name,
- workspaceId,
- sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
- });
+ if (typeof code === 'string') {
+ typescriptCode = code;
+ } else {
+ typescriptCode = await readFileContent(code.createReadStream());
+ }
+
+ const serverlessFunctionId = v4();
const fileFolder = join(
FileFolder.ServerlessFunction,
workspaceId,
- serverlessFunction.id,
+ serverlessFunctionId,
);
+ const sourceCodeFullPath = fileFolder + '/' + SOURCE_FILE_NAME;
+
+ const serverlessFunction = await super.createOne({
+ ...serverlessFunctionInput,
+ id: serverlessFunctionId,
+ workspaceId,
+ sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
+ sourceCodeFullPath,
+ });
+
await this.fileStorageService.write({
file: typescriptCode,
name: SOURCE_FILE_NAME,
- mimeType: mimetype,
+ mimeType: undefined,
folder: fileFolder,
});
await this.serverlessService.build(serverlessFunction);
- await this.serverlessFunctionRepository.update(serverlessFunction.id, {
+ await super.updateOne(serverlessFunctionId, {
syncStatus: ServerlessFunctionSyncStatus.READY,
});
- return await this.serverlessFunctionRepository.findOneByOrFail({
- id: serverlessFunction.id,
- });
+ return await this.findById(serverlessFunctionId);
}
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts
index 0e00aa10390c..8f4bc41c9411 100644
--- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils.ts
@@ -17,6 +17,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => {
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST:
throw new ConflictError(error.message);
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_READY:
+ case ServerlessFunctionExceptionCode.FEATURE_FLAG_INVALID:
throw new ForbiddenError(error.message);
default:
throw new InternalServerError(error.message);
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts
index 3f72b8dae22d..07e5efc095a9 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command.ts
@@ -62,6 +62,7 @@ export class AddStandardIdCommand extends CommandRunner {
IS_MESSAGING_ALIAS_FETCHING_ENABLED: true,
IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true,
IS_FREE_ACCESS_ENABLED: false,
+ IS_FUNCTION_SETTINGS_ENABLED: false,
IS_WORKFLOW_ENABLED: false,
},
);
@@ -81,6 +82,7 @@ export class AddStandardIdCommand extends CommandRunner {
IS_MESSAGING_ALIAS_FETCHING_ENABLED: true,
IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true,
IS_FREE_ACCESS_ENABLED: false,
+ IS_FUNCTION_SETTINGS_ENABLED: false,
IS_WORKFLOW_ENABLED: false,
},
);
diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
index 8c1b94e40066..a73785be8fc3 100644
--- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
+++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
@@ -84,6 +84,7 @@ export {
IconFilter,
IconFilterOff,
IconFocusCentered,
+ IconFunction,
IconForbid,
IconGripVertical,
IconH1,
@@ -127,6 +128,7 @@ export {
IconPhone,
IconPhoto,
IconPilcrow,
+ IconPlayerPlay,
IconPlug,
IconPlus,
IconPresentation,
@@ -153,6 +155,7 @@ export {
IconTags,
IconTarget,
IconTargetArrow,
+ IconTestPipe,
IconTextSize,
IconTimelineEvent,
IconTrash,
diff --git a/packages/twenty-ui/src/theme/constants/CodeDark.ts b/packages/twenty-ui/src/theme/constants/CodeDark.ts
new file mode 100644
index 000000000000..dc675bf1f8e1
--- /dev/null
+++ b/packages/twenty-ui/src/theme/constants/CodeDark.ts
@@ -0,0 +1,10 @@
+import { COLOR } from './Colors';
+
+export const CODE_DARK = {
+ text: {
+ gray: COLOR.gray50,
+ sky: COLOR.sky50,
+ pink: COLOR.pink50,
+ orange: COLOR.orange40,
+ },
+};
diff --git a/packages/twenty-ui/src/theme/constants/CodeLight.ts b/packages/twenty-ui/src/theme/constants/CodeLight.ts
new file mode 100644
index 000000000000..3f2f47fa5e97
--- /dev/null
+++ b/packages/twenty-ui/src/theme/constants/CodeLight.ts
@@ -0,0 +1,10 @@
+import { COLOR } from './Colors';
+
+export const CODE_LIGHT = {
+ text: {
+ gray: COLOR.gray50,
+ sky: COLOR.sky50,
+ pink: COLOR.pink50,
+ orange: COLOR.orange40,
+ },
+};
diff --git a/packages/twenty-ui/src/theme/constants/ThemeDark.ts b/packages/twenty-ui/src/theme/constants/ThemeDark.ts
index b776131262dc..6cbcf7787233 100644
--- a/packages/twenty-ui/src/theme/constants/ThemeDark.ts
+++ b/packages/twenty-ui/src/theme/constants/ThemeDark.ts
@@ -9,6 +9,7 @@ import { BOX_SHADOW_DARK } from './BoxShadowDark';
import { FONT_DARK } from './FontDark';
import { TAG_DARK } from './TagDark';
import { THEME_COMMON } from './ThemeCommon';
+import { CODE_DARK } from './CodeDark';
export const THEME_DARK: ThemeType = {
...THEME_COMMON,
@@ -22,5 +23,6 @@ export const THEME_DARK: ThemeType = {
name: 'dark',
snackBar: SNACK_BAR_DARK,
tag: TAG_DARK,
+ code: CODE_DARK,
},
};
diff --git a/packages/twenty-ui/src/theme/constants/ThemeLight.ts b/packages/twenty-ui/src/theme/constants/ThemeLight.ts
index cb89370c741b..259552e3ca51 100644
--- a/packages/twenty-ui/src/theme/constants/ThemeLight.ts
+++ b/packages/twenty-ui/src/theme/constants/ThemeLight.ts
@@ -8,6 +8,7 @@ import { BOX_SHADOW_LIGHT } from './BoxShadowLight';
import { FONT_LIGHT } from './FontLight';
import { TAG_LIGHT } from './TagLight';
import { THEME_COMMON } from './ThemeCommon';
+import { CODE_LIGHT } from './CodeLight';
export const THEME_LIGHT = {
...THEME_COMMON,
@@ -21,5 +22,6 @@ export const THEME_LIGHT = {
name: 'light',
snackBar: SNACK_BAR_LIGHT,
tag: TAG_LIGHT,
+ code: CODE_LIGHT,
},
};
diff --git a/packages/twenty-ui/src/theme/index.ts b/packages/twenty-ui/src/theme/index.ts
index b525f14ae4ad..f1faaf6fcf01 100644
--- a/packages/twenty-ui/src/theme/index.ts
+++ b/packages/twenty-ui/src/theme/index.ts
@@ -10,6 +10,8 @@ export * from './constants/BorderDark';
export * from './constants/BorderLight';
export * from './constants/BoxShadowDark';
export * from './constants/BoxShadowLight';
+export * from './constants/CodeDark';
+export * from './constants/CodeLight';
export * from './constants/Colors';
export * from './constants/FontCommon';
export * from './constants/FontDark';
diff --git a/yarn.lock b/yarn.lock
index dc3df1fbfa1e..1a4e436ca04e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8671,6 +8671,30 @@ __metadata:
languageName: node
linkType: hard
+"@monaco-editor/loader@npm:^1.4.0":
+ version: 1.4.0
+ resolution: "@monaco-editor/loader@npm:1.4.0"
+ dependencies:
+ state-local: "npm:^1.0.6"
+ peerDependencies:
+ monaco-editor: ">= 0.21.0 < 1"
+ checksum: 10c0/68938350adf2f42363a801d87f5d00c87d397d4cba7041141af10a9216bd35c85209b4723a26d56cb32e68eef61471deda2a450f8892891118fbdce7fa1d987d
+ languageName: node
+ linkType: hard
+
+"@monaco-editor/react@npm:^4.6.0":
+ version: 4.6.0
+ resolution: "@monaco-editor/react@npm:4.6.0"
+ dependencies:
+ "@monaco-editor/loader": "npm:^1.4.0"
+ peerDependencies:
+ monaco-editor: ">= 0.25.0 < 1"
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+ checksum: 10c0/231e9a9b66a530db326f6732de0ebffcce6b79dcfaf4948923d78b9a3d5e2a04b7a06e1f85bbbca45a5ae15c107a124e4c5c46cabadc20a498fb5f2d05f7f379
+ languageName: node
+ linkType: hard
+
"@motionone/animation@npm:^10.12.0":
version: 10.16.3
resolution: "@motionone/animation@npm:10.16.3"
@@ -39375,6 +39399,13 @@ __metadata:
languageName: node
linkType: hard
+"monaco-editor@npm:^0.50.0":
+ version: 0.50.0
+ resolution: "monaco-editor@npm:0.50.0"
+ checksum: 10c0/79189c926c2fc1e3a3b9118e80911599bf18108018fe176c7b47a27b4856b544129f9a59c9a5c321d154d6a30a8d9c231684246e9382f4f18329a548d11cb4d6
+ languageName: node
+ linkType: hard
+
"mri@npm:^1.1.0":
version: 1.2.0
resolution: "mri@npm:1.2.0"
@@ -47161,6 +47192,13 @@ __metadata:
languageName: node
linkType: hard
+"state-local@npm:^1.0.6":
+ version: 1.0.7
+ resolution: "state-local@npm:1.0.7"
+ checksum: 10c0/8dc7daeac71844452fafb514a6d6b6f40d7e2b33df398309ea1c7b3948d6110c57f112b7196500a10c54fdde40291488c52c875575670fb5c819602deca48bd9
+ languageName: node
+ linkType: hard
+
"static-browser-server@npm:1.0.3":
version: 1.0.3
resolution: "static-browser-server@npm:1.0.3"
@@ -49095,6 +49133,7 @@ __metadata:
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga-nestjs-npm-2.1.0-cb509e6047.patch"
"@langchain/mistralai": "npm:^0.0.24"
"@langchain/openai": "npm:^0.1.3"
+ "@monaco-editor/react": "npm:^4.6.0"
"@nestjs/cache-manager": "npm:^2.2.1"
"@nestjs/cli": "npm:10.3.0"
"@nestjs/devtools-integration": "npm:^0.1.6"
@@ -49125,6 +49164,7 @@ __metadata:
lodash.omitby: "npm:^4.6.0"
lodash.uniq: "npm:^4.5.0"
lodash.uniqby: "npm:^4.7.0"
+ monaco-editor: "npm:^0.50.0"
passport: "npm:^0.7.0"
psl: "npm:^1.9.0"
rimraf: "npm:^5.0.5"