diff --git a/.github/workflows/ci-internal-apps.yaml b/.github/workflows/ci-internal-apps.yaml index be5d476122..7339b85056 100644 --- a/.github/workflows/ci-internal-apps.yaml +++ b/.github/workflows/ci-internal-apps.yaml @@ -66,7 +66,7 @@ jobs: // keep this PR small while the remaining apps are made CI-ready. Remove an // app from this list once its checks pass, and delete the list entirely // once every application is covered. - const CI_EXCLUDED_APPLICATIONS = ['call-recording', 'exa', 'people-data-labs', 'self-hosting', 'twenty-discord', 'twenty-fireflies', 'twenty-for-twenty', 'twenty-linear', 'twenty-meeting-bot', 'twenty-partners', 'twenty-slack']; + const CI_EXCLUDED_APPLICATIONS = ['call-recording', 'exa', 'people-data-labs', 'self-hosting', 'twenty-discord', 'twenty-fireflies', 'twenty-for-twenty', 'twenty-linear', 'twenty-meeting-bot', 'twenty-partners']; const eventName = process.env.EVENT_NAME; const changedFiles = JSON.parse(process.env.CHANGED_FILES || '[]'); const changedApps = new Set(); diff --git a/packages/twenty-apps/.oxlintrc.base.json b/packages/twenty-apps/.oxlintrc.base.json new file mode 100644 index 0000000000..58b5a7f094 --- /dev/null +++ b/packages/twenty-apps/.oxlintrc.base.json @@ -0,0 +1,15 @@ +{ + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["twenty-shared", "twenty-shared/*"], + "message": "twenty-apps applications must not import from twenty-shared directly. Import the runtime types and helpers you need from twenty-sdk instead so the app bundle stays minimal." + } + ] + } + ] + } +} diff --git a/packages/twenty-apps/internal/call-recording/.oxlintrc.json b/packages/twenty-apps/internal/call-recording/.oxlintrc.json index 460199b1e6..bce62aedde 100644 --- a/packages/twenty-apps/internal/call-recording/.oxlintrc.json +++ b/packages/twenty-apps/internal/call-recording/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript", "import", "unicorn"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/exa/.oxlintrc.json b/packages/twenty-apps/internal/exa/.oxlintrc.json index 87c62c5183..34c54bff59 100644 --- a/packages/twenty-apps/internal/exa/.oxlintrc.json +++ b/packages/twenty-apps/internal/exa/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/people-data-labs/.oxlintrc.json b/packages/twenty-apps/internal/people-data-labs/.oxlintrc.json index 670fc042c4..ee3eba2498 100644 --- a/packages/twenty-apps/internal/people-data-labs/.oxlintrc.json +++ b/packages/twenty-apps/internal/people-data-labs/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript", "import", "unicorn"], "categories": { "correctness": "off" @@ -44,23 +45,5 @@ } ], "typescript/no-explicit-any": "off" - }, - "overrides": [ - { - "files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"], - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["twenty-shared", "twenty-shared/*"], - "message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal." - } - ] - } - ] - } - } - ] + } } diff --git a/packages/twenty-apps/internal/self-hosting/.oxlintrc.json b/packages/twenty-apps/internal/self-hosting/.oxlintrc.json index 87c62c5183..34c54bff59 100644 --- a/packages/twenty-apps/internal/self-hosting/.oxlintrc.json +++ b/packages/twenty-apps/internal/self-hosting/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json b/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json index e0ce53c21f..958be72869 100644 --- a/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/twenty-fireflies/.oxlintrc.json b/packages/twenty-apps/internal/twenty-fireflies/.oxlintrc.json index e0ce53c21f..958be72869 100644 --- a/packages/twenty-apps/internal/twenty-fireflies/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-fireflies/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/twenty-for-twenty/.oxlintrc.json b/packages/twenty-apps/internal/twenty-for-twenty/.oxlintrc.json index 87c62c5183..34c54bff59 100644 --- a/packages/twenty-apps/internal/twenty-for-twenty/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-for-twenty/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/twenty-last-contact/.oxlintrc.json b/packages/twenty-apps/internal/twenty-last-contact/.oxlintrc.json index a1d10264b4..2c2035b0fe 100644 --- a/packages/twenty-apps/internal/twenty-last-contact/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-last-contact/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["react", "typescript", "import", "unicorn"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json b/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json index 87c62c5183..34c54bff59 100644 --- a/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" diff --git a/packages/twenty-apps/internal/twenty-meeting-bot/.oxlintrc.json b/packages/twenty-apps/internal/twenty-meeting-bot/.oxlintrc.json index 369b9cfd76..34c54bff59 100644 --- a/packages/twenty-apps/internal/twenty-meeting-bot/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-meeting-bot/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" @@ -15,23 +16,5 @@ } ], "typescript/no-explicit-any": "off" - }, - "overrides": [ - { - "files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"], - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["twenty-shared", "twenty-shared/*"], - "message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal." - } - ] - } - ] - } - } - ] + } } diff --git a/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json b/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json index 369b9cfd76..34c54bff59 100644 --- a/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json @@ -1,5 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], "plugins": ["typescript"], "categories": { "correctness": "off" @@ -15,23 +16,5 @@ } ], "typescript/no-explicit-any": "off" - }, - "overrides": [ - { - "files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"], - "rules": { - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["twenty-shared", "twenty-shared/*"], - "message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal." - } - ] - } - ] - } - } - ] + } } diff --git a/packages/twenty-apps/internal/twenty-slack/.gitignore b/packages/twenty-apps/internal/twenty-slack/.gitignore new file mode 100644 index 0000000000..6cd0610624 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn + +# codegen +generated + +# testing +/coverage + +# dev +/dist/ + +.twenty + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# typescript +*.tsbuildinfo +*.d.ts diff --git a/packages/twenty-apps/internal/twenty-slack/.nvmrc b/packages/twenty-apps/internal/twenty-slack/.nvmrc new file mode 100644 index 0000000000..341cb50613 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/.nvmrc @@ -0,0 +1 @@ +24.5.0 diff --git a/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json b/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json index e0ce53c21f..2c2035b0fe 100644 --- a/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json @@ -1,18 +1,58 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript"], + "extends": ["../../.oxlintrc.base.json"], + "plugins": ["react", "typescript", "import", "unicorn"], "categories": { "correctness": "off" }, "ignorePatterns": ["node_modules", "dist"], "rules": { + "func-style": ["error", "declaration", { "allowArrowFunctions": true }], + "no-console": "off", + "no-control-regex": "off", + "no-debugger": "error", + "no-duplicate-imports": "error", + "no-undef": "off", "no-unused-vars": "off", + "no-redeclare": "off", + "import/no-duplicates": "error", + "typescript/no-redeclare": "error", + "typescript/ban-ts-comment": "error", + "typescript/consistent-type-imports": [ + "error", + { + "prefer": "type-imports", + "fixStyle": "inline-type-imports" + } + ], + "typescript/explicit-function-return-type": "off", + "typescript/explicit-module-boundary-types": "off", + "typescript/no-empty-object-type": [ + "error", + { + "allowInterfaces": "with-single-extends" + } + ], + "typescript/no-empty-function": "off", + "typescript/no-explicit-any": "off", "typescript/no-unused-vars": [ "warn", { + "vars": "all", + "varsIgnorePattern": "^_", + "args": "after-used", "argsIgnorePattern": "^_" } ], - "typescript/no-explicit-any": "off" + "react/no-unescaped-entities": "off", + "react/prop-types": "off", + "react/jsx-key": "off", + "react/display-name": "off", + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off", + "react/jsx-no-useless-fragment": "off", + "react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" } } diff --git a/packages/twenty-apps/internal/twenty-slack/.yarn/install-state.gz b/packages/twenty-apps/internal/twenty-slack/.yarn/install-state.gz index 125360524e..9f12c12c15 100644 Binary files a/packages/twenty-apps/internal/twenty-slack/.yarn/install-state.gz and b/packages/twenty-apps/internal/twenty-slack/.yarn/install-state.gz differ diff --git a/packages/twenty-apps/internal/twenty-slack/.yarnrc.yml b/packages/twenty-apps/internal/twenty-slack/.yarnrc.yml new file mode 100644 index 0000000000..3186f3f079 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/packages/twenty-apps/internal/twenty-slack/package.json b/packages/twenty-apps/internal/twenty-slack/package.json index 4f71201856..6a747e6e0c 100644 --- a/packages/twenty-apps/internal/twenty-slack/package.json +++ b/packages/twenty-apps/internal/twenty-slack/package.json @@ -16,20 +16,25 @@ "twenty": "twenty", "lint": "oxlint -c .oxlintrc.json .", "lint:fix": "oxlint --fix -c .oxlintrc.json .", + "typecheck": "tsgo --noEmit -p tsconfig.spec.json", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:unit": "vitest run --config vitest.unit.config.ts" }, "dependencies": { - "@slack/web-api": "^7.8.0", - "twenty-sdk": "2.13.0" + "@slack/web-api": "^7.8.0" }, "devDependencies": { "@types/node": "^24.7.2", "@types/react": "^18.2.0", + "@typescript/native-preview": "^7.0.0-dev.20260116.1", "oxlint": "^0.16.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "twenty-client-sdk": "^2.14.0", + "twenty-sdk": "^2.14.0", "typescript": "^5.9.3", + "vite-tsconfig-paths": "^4.2.1", "vitest": "^4.0.0" } } diff --git a/packages/twenty-apps/internal/twenty-slack/src/__tests__/global-setup.ts b/packages/twenty-apps/internal/twenty-slack/src/__tests__/global-setup.ts new file mode 100644 index 0000000000..e0f8c23b05 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/__tests__/global-setup.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { appDevOnce, appUninstall } from 'twenty-sdk/cli'; + +const APP_PATH = process.cwd(); +const CONFIG_DIR = path.join(os.homedir(), '.twenty'); + +function validateEnv(): { apiUrl: string; apiKey: string } { + const apiUrl = process.env.TWENTY_API_URL; + const apiKey = process.env.TWENTY_API_KEY; + + if (!apiUrl || !apiKey) { + throw new Error( + 'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' + + 'Start a local server: yarn twenty docker:start\n' + + 'Or set them in vitest env config.', + ); + } + + return { apiUrl, apiKey }; +} + +async function checkServer(apiUrl: string) { + let response: Response; + + try { + response = await fetch(`${apiUrl}/healthz`); + } catch { + throw new Error( + `Twenty server is not reachable at ${apiUrl}. ` + + 'Make sure the server is running before executing integration tests.', + ); + } + + if (!response.ok) { + throw new Error(`Server at ${apiUrl} returned ${response.status}`); + } +} + +function writeConfig(apiUrl: string, apiKey: string) { + const payload = JSON.stringify( + { + remotes: { + local: { apiUrl, apiKey, accessToken: apiKey }, + }, + defaultRemote: 'local', + }, + null, + 2, + ); + + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload); +} + +export async function setup() { + const { apiUrl, apiKey } = validateEnv(); + + await checkServer(apiUrl); + + writeConfig(apiUrl, apiKey); + + await appUninstall({ appPath: APP_PATH }).catch(() => {}); + + const result = await appDevOnce({ + appPath: APP_PATH, + onProgress: (message: string) => console.log(`[dev] ${message}`), + }); + + if (!result.success) { + throw new Error( + `Dev sync failed: ${result.error?.message ?? 'Unknown error'}`, + ); + } +} + +export async function teardown() { + const uninstallResult = await appUninstall({ appPath: APP_PATH }); + + if (!uninstallResult.success) { + console.warn( + `App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`, + ); + } +} diff --git a/packages/twenty-apps/internal/twenty-slack/src/__tests__/twenty-slack.integration-test.ts b/packages/twenty-apps/internal/twenty-slack/src/__tests__/twenty-slack.integration-test.ts new file mode 100644 index 0000000000..c9d3de3234 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/__tests__/twenty-slack.integration-test.ts @@ -0,0 +1,25 @@ +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; +import { describe, expect, it } from 'vitest'; + +import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +describe('App installation', () => { + it('should find the installed Slack app in the applications list', async () => { + const client = new MetadataApiClient(); + + const result = await client.query({ + findManyApplications: { + id: true, + name: true, + universalIdentifier: true, + }, + }); + + const matchingApplication = result.findManyApplications.find( + (application: { universalIdentifier: string }) => + application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, + ); + + expect(matchingApplication).toBeDefined(); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx b/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx index ba20b4eb83..5025cf9ac6 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx +++ b/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx @@ -1,20 +1,63 @@ -import { - useCallback, - useEffect, - useState, - type CSSProperties, - type SyntheticEvent, -} from 'react'; +import { type CSSProperties, type SyntheticEvent, useCallback, useEffect, useState } from 'react'; import { defineFrontComponent } from 'twenty-sdk/define'; -import { - closeSidePanel, - enqueueSnackbar, - unmountFrontComponent, -} from 'twenty-sdk/front-component'; -import { themeCssVariables } from 'twenty-sdk/ui'; +import { closeSidePanel, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component'; import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +// Workaround: 'twenty-sdk/ui' currently fails typecheck because it re-exports +// from the unresolvable 'twenty-ui-deprecated'. Inline only the theme tokens +// this component uses, keeping the same runtime CSS-variable values. Revert to +// `import { themeCssVariables } from 'twenty-sdk/ui'` once the SDK export is fixed. +const themeCssVariables = { + spacing: { + '1': 'var(--t-spacing-1)', + '2': 'var(--t-spacing-2)', + '3': 'var(--t-spacing-3)', + '4': 'var(--t-spacing-4)', + '8': 'var(--t-spacing-8)', + '20': 'var(--t-spacing-20)', + }, + accent: { + accent4060: 'var(--t-accent-accent4060)', + }, + background: { + primary: 'var(--t-background-primary)', + secondary: 'var(--t-background-secondary)', + }, + border: { + color: { + medium: 'var(--t-border-color-medium)', + light: 'var(--t-border-color-light)', + }, + radius: { + sm: 'var(--t-border-radius-sm)', + }, + }, + font: { + color: { + primary: 'var(--t-font-color-primary)', + secondary: 'var(--t-font-color-secondary)', + tertiary: 'var(--t-font-color-tertiary)', + inverted: 'var(--t-font-color-inverted)', + danger: 'var(--t-font-color-danger)', + }, + size: { + xs: 'var(--t-font-size-xs)', + sm: 'var(--t-font-size-sm)', + md: 'var(--t-font-size-md)', + }, + weight: { + regular: 'var(--t-font-weight-regular)', + medium: 'var(--t-font-weight-medium)', + semiBold: 'var(--t-font-weight-semi-bold)', + }, + family: 'var(--t-font-family)', + }, + color: { + blue: 'var(--t-color-blue)', + }, +}; + type SlackChannel = { id: string; name: string; diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-add-reaction-handler.test.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-add-reaction-handler.test.ts new file mode 100644 index 0000000000..8b5dad135d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-add-reaction-handler.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { slackAddReactionHandler } from 'src/logic-functions/handlers/slack-add-reaction-handler'; + +const { getSlackClientMock, addReactionMock } = vi.hoisted(() => ({ + getSlackClientMock: vi.fn(), + addReactionMock: vi.fn(), +})); + +vi.mock('src/logic-functions/utils/get-slack-client', () => ({ + getSlackClient: getSlackClientMock, +})); + +const CHANNEL_ID = 'C0123456789'; +const MESSAGE_TS = '1700000000.000100'; + +describe('slackAddReactionHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + getSlackClientMock.mockResolvedValue({ + success: true, + client: { reactions: { add: addReactionMock } }, + }); + }); + + it('should return a failure result and skip reacting when Slack is not connected', async () => { + getSlackClientMock.mockResolvedValue({ + success: false, + error: 'Slack is not connected.', + }); + + const result = await slackAddReactionHandler({ + slackChannelId: CHANNEL_ID, + messageTimestamp: MESSAGE_TS, + emojiName: 'thumbsup', + }); + + expect(result.success).toBe(false); + expect(addReactionMock).not.toHaveBeenCalled(); + }); + + it('should add the reaction with a colon-stripped, trimmed emoji name', async () => { + addReactionMock.mockResolvedValue({ ok: true }); + + const result = await slackAddReactionHandler({ + slackChannelId: CHANNEL_ID, + messageTimestamp: MESSAGE_TS, + emojiName: ' white_check_mark ', + }); + + expect(addReactionMock).toHaveBeenCalledWith({ + channel: CHANNEL_ID, + timestamp: MESSAGE_TS, + name: 'white_check_mark', + }); + expect(result).toEqual({ + success: true, + message: 'Reaction "white_check_mark" added to the message.', + slackTs: MESSAGE_TS, + channel: CHANNEL_ID, + }); + }); + + it('should return a failure result when the Slack API throws', async () => { + addReactionMock.mockRejectedValue(new Error('already_reacted')); + + const result = await slackAddReactionHandler({ + slackChannelId: CHANNEL_ID, + messageTimestamp: MESSAGE_TS, + emojiName: 'eyes', + }); + + expect(result).toEqual({ + success: false, + message: 'Failed to add Slack reaction', + error: 'already_reacted', + }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts new file mode 100644 index 0000000000..a89051b292 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler'; + +const { getSlackClientMock, postMessageMock } = vi.hoisted(() => ({ + getSlackClientMock: vi.fn(), + postMessageMock: vi.fn(), +})); + +vi.mock('src/logic-functions/utils/get-slack-client', () => ({ + getSlackClient: getSlackClientMock, +})); + +const CHANNEL_ID = 'C0123456789'; + +describe('slackPostMessageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + getSlackClientMock.mockResolvedValue({ + success: true, + client: { chat: { postMessage: postMessageMock } }, + }); + }); + + it('should return a failure result and skip posting when Slack is not connected', async () => { + getSlackClientMock.mockResolvedValue({ + success: false, + error: 'Slack is not connected.', + }); + + const result = await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: 'hello', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Slack is not connected.'); + expect(postMessageMock).not.toHaveBeenCalled(); + }); + + it('should post the message and return the Slack timestamp and channel', async () => { + postMessageMock.mockResolvedValue({ + ts: '1700000000.000100', + channel: CHANNEL_ID, + }); + + const result = await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: 'hello', + messageFormat: 'markdown', + }); + + expect(postMessageMock).toHaveBeenCalledWith({ + channel: CHANNEL_ID, + thread_ts: undefined, + markdown_text: 'hello', + }); + expect(result).toEqual({ + success: true, + message: 'Message posted to Slack (ts=1700000000.000100).', + slackTs: '1700000000.000100', + channel: CHANNEL_ID, + }); + }); + + it('should reply inside a thread with a trimmed parent timestamp', async () => { + postMessageMock.mockResolvedValue({ + ts: '1700000000.000200', + channel: CHANNEL_ID, + }); + + await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: 'in thread', + parentMessageTimestamp: ' 1699999999.000100 ', + }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ thread_ts: '1699999999.000100' }), + ); + }); + + it('should not set thread_ts when the parent timestamp is blank', async () => { + postMessageMock.mockResolvedValue({ ts: '1700000000.000300' }); + + await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: 'standalone', + parentMessageTimestamp: ' ', + }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ thread_ts: undefined }), + ); + }); + + it('should treat a runtime null parent timestamp as no thread without throwing', async () => { + postMessageMock.mockResolvedValue({ ts: '1700000000.000400' }); + + await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: 'standalone', + parentMessageTimestamp: null as unknown as undefined, + }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ thread_ts: undefined }), + ); + }); + + it('should return a failure result when the Slack API throws', async () => { + postMessageMock.mockRejectedValue(new Error('channel_not_found')); + + const result = await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: 'hello', + }); + + expect(result).toEqual({ + success: false, + message: 'Failed to post Slack message', + error: 'channel_not_found', + }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts index 6dd4b0f396..766bdf4e89 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts @@ -1,12 +1,10 @@ -import { isDefined } from 'twenty-shared/utils'; - import { type SlackChannelType, - type SlackListChannelsInput, + type SlackListChannelsInput } from 'src/logic-functions/types/slack-list-channels-input.type'; import { type SlackListChannelsResult, - type SlackListChannelsResultChannel, + type SlackListChannelsResultChannel } from 'src/logic-functions/types/slack-list-channels-result.type'; import { getSlackClient } from 'src/logic-functions/utils/get-slack-client'; @@ -63,7 +61,7 @@ export const slackListChannelsHandler = async ( break; } - if (!isDefined(channel.id) || !isDefined(channel.name)) { + if (channel.id == null || channel.name == null) { continue; } @@ -81,7 +79,7 @@ export const slackListChannelsHandler = async ( const nextCursor = response.response_metadata?.next_cursor; - if (!isDefined(nextCursor) || nextCursor.length === 0) { + if (nextCursor == null || nextCursor.length === 0) { break; } diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts index c981ea67b7..9c6e1f5460 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts @@ -1,5 +1,3 @@ -import { isDefined } from 'twenty-shared/utils'; - import { type SlackPostMessageInput } from 'src/logic-functions/types/slack-post-message-input.type'; import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type'; import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields'; @@ -32,7 +30,7 @@ export const slackPostMessageHandler = async ( const data = await client.chat.postMessage({ channel: parameters.slackChannelId, thread_ts: - isDefined(parentTimestamp) && parentTimestamp.trim().length > 0 + parentTimestamp != null && parentTimestamp.trim().length > 0 ? parentTimestamp.trim() : undefined, ...bodyFields, diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-add-reaction.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-add-reaction.ts index 7af2bafb57..a3e93db375 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-add-reaction.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-add-reaction.ts @@ -1,8 +1,8 @@ import { defineLogicFunction } from 'twenty-sdk/define'; -import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function'; import { SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackAddReactionHandler } from 'src/logic-functions/handlers/slack-add-reaction-handler'; +import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema'; import { slackAddReactionInputSchema } from './schemas/slack-add-reaction-input.schema'; export default defineLogicFunction({ diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-delete-message.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-delete-message.ts index f69f830788..59a157ea17 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-delete-message.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-delete-message.ts @@ -1,8 +1,8 @@ import { defineLogicFunction } from 'twenty-sdk/define'; -import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function'; import { SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackDeleteMessageHandler } from 'src/logic-functions/handlers/slack-delete-message-handler'; +import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema'; import { slackDeleteMessageInputSchema } from './schemas/slack-delete-message-input.schema'; export default defineLogicFunction({ diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels-route.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels-route.ts index 191432b562..d41039ea7b 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels-route.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels-route.ts @@ -1,5 +1,4 @@ -import type { RoutePayload } from 'twenty-sdk/define'; -import { defineLogicFunction } from 'twenty-sdk/define'; +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; import { SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler'; diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels.ts index b88711ef3e..528276db41 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels.ts @@ -1,8 +1,8 @@ import { defineLogicFunction } from 'twenty-sdk/define'; -import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function'; import { SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler'; +import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema'; import { slackListChannelsInputSchema } from './schemas/slack-list-channels-input.schema'; export default defineLogicFunction({ diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts index 6ed951c1f6..c1e4d03c67 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts @@ -1,8 +1,8 @@ import { defineLogicFunction } from 'twenty-sdk/define'; -import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function'; import { SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackPostEphemeralMessageHandler } from 'src/logic-functions/handlers/slack-post-ephemeral-message-handler'; +import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema'; import { slackPostEphemeralMessageInputSchema } from './schemas/slack-post-ephemeral-message-input.schema'; export default defineLogicFunction({ diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message-route.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message-route.ts index 8bbe4408aa..5e9d509eda 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message-route.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message-route.ts @@ -1,5 +1,4 @@ -import { defineLogicFunction } from 'twenty-sdk/define'; -import type { RoutePayload } from 'twenty-sdk/define'; +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; import { SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler'; diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message.ts index 5f2c8cdb60..9e47d09244 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message.ts @@ -1,8 +1,8 @@ import { defineLogicFunction } from 'twenty-sdk/define'; -import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function'; import { SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler'; +import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema'; import { slackPostMessageInputSchema } from './schemas/slack-post-message-input.schema'; export default defineLogicFunction({ diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-update-message.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-update-message.ts index 100ab4064c..ef9d58899d 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-update-message.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-update-message.ts @@ -1,8 +1,8 @@ import { defineLogicFunction } from 'twenty-sdk/define'; -import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function'; import { SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; import { slackUpdateMessageHandler } from 'src/logic-functions/handlers/slack-update-message-handler'; +import { jsonSchemaToInputSchema } from 'src/logic-functions/utils/json-schema-to-input-schema'; import { slackUpdateMessageInputSchema } from './schemas/slack-update-message-input.schema'; export default defineLogicFunction({ diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-chat-message-body-fields.test.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-chat-message-body-fields.test.ts new file mode 100644 index 0000000000..b686903850 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-chat-message-body-fields.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields'; + +describe('getSlackChatMessageBodyFields', () => { + it('should send markdown_text when the format is markdown', () => { + expect(getSlackChatMessageBodyFields('hello', 'markdown')).toEqual({ + markdown_text: 'hello', + }); + }); + + it('should send plain text with mrkdwn disabled when the format is plain', () => { + expect(getSlackChatMessageBodyFields('hello', 'plain')).toEqual({ + text: 'hello', + mrkdwn: false, + }); + }); + + it('should fall back to a plain text body when no format is provided', () => { + expect(getSlackChatMessageBodyFields('hello', undefined)).toEqual({ + text: 'hello', + }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-connection.test.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-connection.test.ts new file mode 100644 index 0000000000..26ad343416 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-connection.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSlackConnection } from 'src/logic-functions/utils/get-slack-connection'; + +const { listConnectionsMock } = vi.hoisted(() => ({ + listConnectionsMock: vi.fn(), +})); + +vi.mock('twenty-sdk/logic-function', () => ({ + listConnections: listConnectionsMock, +})); + +describe('getSlackConnection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should query connections for the slack provider', async () => { + listConnectionsMock.mockResolvedValue([ + { visibility: 'workspace', accessToken: 'workspace-token' }, + ]); + + await getSlackConnection(); + + expect(listConnectionsMock).toHaveBeenCalledWith({ providerName: 'slack' }); + }); + + it('should prefer a workspace-visibility connection over a personal one', async () => { + listConnectionsMock.mockResolvedValue([ + { visibility: 'personal', accessToken: 'personal-token' }, + { visibility: 'workspace', accessToken: 'workspace-token' }, + ]); + + const result = await getSlackConnection(); + + expect(result).toEqual({ success: true, accessToken: 'workspace-token' }); + }); + + it('should fall back to the first connection when none are workspace-visible', async () => { + listConnectionsMock.mockResolvedValue([ + { visibility: 'personal', accessToken: 'first-token' }, + { visibility: 'personal', accessToken: 'second-token' }, + ]); + + const result = await getSlackConnection(); + + expect(result).toEqual({ success: true, accessToken: 'first-token' }); + }); + + it('should fail when there is no Slack connection', async () => { + listConnectionsMock.mockResolvedValue([]); + + const result = await getSlackConnection(); + + expect(result.success).toBe(false); + }); + + it('should fail gracefully when listing connections throws', async () => { + listConnectionsMock.mockRejectedValue(new Error('network down')); + + const result = await getSlackConnection(); + + expect(result).toEqual({ success: false, error: 'network down' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/slack-tool-failure.test.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/slack-tool-failure.test.ts new file mode 100644 index 0000000000..01f1f5edac --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/slack-tool-failure.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure'; + +describe('slackToolFailure', () => { + it('should surface the underlying Error message', () => { + const result = slackToolFailure( + 'Failed to post Slack message', + new Error('channel_not_found'), + ); + + expect(result).toEqual({ + success: false, + message: 'Failed to post Slack message', + error: 'channel_not_found', + }); + }); + + it('should use a generic error when the thrown value is not an Error', () => { + const result = slackToolFailure('Failed to post Slack message', 'boom'); + + expect(result).toEqual({ + success: false, + message: 'Failed to post Slack message', + error: 'Slack request failed', + }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/json-schema-to-input-schema.ts b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/json-schema-to-input-schema.ts new file mode 100644 index 0000000000..d89e400dcb --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/json-schema-to-input-schema.ts @@ -0,0 +1,66 @@ +import { type InputJsonSchema } from 'twenty-sdk/logic-function'; + +type InputSchemaProperty = { + type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown'; + enum?: string[]; + items?: InputSchemaProperty; + properties?: Record; + multiline?: boolean; + label?: string; +}; + +const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => { + const property: InputSchemaProperty = { type: 'unknown' }; + + switch (jsonSchema.type) { + case 'string': + property.type = 'string'; + break; + case 'number': + case 'integer': + property.type = 'number'; + break; + case 'boolean': + property.type = 'boolean'; + break; + case 'array': + property.type = 'array'; + if (jsonSchema.items) { + property.items = convertProperty(jsonSchema.items); + } + break; + case 'object': + property.type = 'object'; + if (jsonSchema.properties) { + property.properties = Object.fromEntries( + Object.entries(jsonSchema.properties).map(([key, value]) => [ + key, + convertProperty(value), + ]), + ); + } + break; + default: + property.type = 'unknown'; + } + + if (Array.isArray(jsonSchema.enum)) { + property.enum = jsonSchema.enum.filter( + (value): value is string => typeof value === 'string', + ); + } + + if (jsonSchema.multiline === true) { + property.multiline = true; + } + + if (typeof jsonSchema.label === 'string' && jsonSchema.label.length > 0) { + property.label = jsonSchema.label; + } + + return property; +}; + +export const jsonSchemaToInputSchema = ( + jsonSchema: InputJsonSchema, +): InputSchemaProperty[] => [convertProperty(jsonSchema)]; diff --git a/packages/twenty-apps/internal/twenty-slack/src/roles/default-function.role.ts b/packages/twenty-apps/internal/twenty-slack/src/roles/default-function.role.ts index 92c75617c5..41aceaa47a 100644 --- a/packages/twenty-apps/internal/twenty-slack/src/roles/default-function.role.ts +++ b/packages/twenty-apps/internal/twenty-slack/src/roles/default-function.role.ts @@ -17,5 +17,5 @@ export default defineRole({ canBeAssignedToApiKeys: false, objectPermissions: [], fieldPermissions: [], - permissionFlags: [], + permissionFlagUniversalIdentifiers: [], }); diff --git a/packages/twenty-apps/internal/twenty-slack/tsconfig.json b/packages/twenty-apps/internal/twenty-slack/tsconfig.json index 80f5e7ceb0..abacf4dcf6 100644 --- a/packages/twenty-apps/internal/twenty-slack/tsconfig.json +++ b/packages/twenty-apps/internal/twenty-slack/tsconfig.json @@ -27,5 +27,16 @@ "~/*": ["./*"] } }, - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.integration-test.ts" + ], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ] } diff --git a/packages/twenty-apps/internal/twenty-slack/tsconfig.spec.json b/packages/twenty-apps/internal/twenty-slack/tsconfig.spec.json new file mode 100644 index 0000000000..ea69a6c010 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/twenty-apps/internal/twenty-slack/vitest.config.ts b/packages/twenty-apps/internal/twenty-slack/vitest.config.ts index 6ec74eee27..055af77dbe 100644 --- a/packages/twenty-apps/internal/twenty-slack/vitest.config.ts +++ b/packages/twenty-apps/internal/twenty-slack/vitest.config.ts @@ -1,7 +1,31 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; +const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020'; +const TWENTY_API_KEY = + process.env.TWENTY_API_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc'; + +// Make env vars available to globalSetup (test.env only applies to workers) +process.env.TWENTY_API_URL = TWENTY_API_URL; +process.env.TWENTY_API_KEY = TWENTY_API_KEY; + export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: ['tsconfig.spec.json'], + ignoreConfigErrors: true, + }), + ], test: { - include: ['src/**/*.test.ts'], + testTimeout: 120_000, + hookTimeout: 120_000, + fileParallelism: false, + include: ['src/**/*.integration-test.ts'], + globalSetup: ['src/__tests__/global-setup.ts'], + env: { + TWENTY_API_URL, + TWENTY_API_KEY, + }, }, }); diff --git a/packages/twenty-apps/internal/twenty-slack/vitest.unit.config.ts b/packages/twenty-apps/internal/twenty-slack/vitest.unit.config.ts new file mode 100644 index 0000000000..e0d140015b --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/vitest.unit.config.ts @@ -0,0 +1,14 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: ['tsconfig.spec.json'], + ignoreConfigErrors: true, + }), + ], + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-slack/yarn.lock b/packages/twenty-apps/internal/twenty-slack/yarn.lock index ca9afe0a56..766426f13a 100644 --- a/packages/twenty-apps/internal/twenty-slack/yarn.lock +++ b/packages/twenty-apps/internal/twenty-slack/yarn.lock @@ -834,6 +834,87 @@ __metadata: languageName: node linkType: hard +"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260616.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260616.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260616.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260616.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260616.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260616.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260616.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260616.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260616.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260616.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260616.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260616.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260616.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260616.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview@npm:^7.0.0-dev.20260116.1": + version: 7.0.0-dev.20260616.1 + resolution: "@typescript/native-preview@npm:7.0.0-dev.20260616.1" + dependencies: + "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20260616.1" + "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20260616.1" + "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20260616.1" + "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20260616.1" + "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20260616.1" + "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20260616.1" + "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20260616.1" + dependenciesMeta: + "@typescript/native-preview-darwin-arm64": + optional: true + "@typescript/native-preview-darwin-x64": + optional: true + "@typescript/native-preview-linux-arm": + optional: true + "@typescript/native-preview-linux-arm64": + optional: true + "@typescript/native-preview-linux-x64": + optional: true + "@typescript/native-preview-win32-arm64": + optional: true + "@typescript/native-preview-win32-x64": + optional: true + bin: + tsgo: bin/tsgo.js + checksum: 10c0/e858f6a5bc41e615d3a87381ac33c938cbaa3553c47f4f1c3e06682fd96303f624ecd457542b7980a7eb29f73b54da19e8a52650246cc98a145c4a46aa743b40 + languageName: node + linkType: hard + "@vitest/expect@npm:4.1.8": version: 4.1.8 resolution: "@vitest/expect@npm:4.1.8" @@ -1142,7 +1223,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4": +"debug@npm:4, debug@npm:^4.1.1": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -1525,6 +1606,13 @@ __metadata: languageName: node linkType: hard +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: 10c0/a54c029520cf58bda1d8884f72bd49b4cd74e977883268d931fd83bcbd1a9eb96d57c7dbd4ad80148fb9247467ebfb9b215630b2ed7563b2a8de02e1ff7f89d1 + languageName: node + linkType: hard + "gopd@npm:^1.2.0": version: 1.2.0 resolution: "gopd@npm:1.2.0" @@ -2530,6 +2618,20 @@ __metadata: languageName: node linkType: hard +"tsconfck@npm:^3.0.3": + version: 3.1.6 + resolution: "tsconfck@npm:3.1.6" + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: 10c0/269c3c513540be44844117bb9b9258fe6f8aeab026d32aeebf458d5299125f330711429dbb556dbf125a0bc25f4a81e6c24ac96de2740badd295c3fb400f66c4 + languageName: node + linkType: hard + "tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -2544,22 +2646,22 @@ __metadata: languageName: node linkType: hard -"twenty-client-sdk@npm:2.13.0": - version: 2.13.0 - resolution: "twenty-client-sdk@npm:2.13.0" +"twenty-client-sdk@npm:2.14.0, twenty-client-sdk@npm:^2.14.0": + version: 2.14.0 + resolution: "twenty-client-sdk@npm:2.14.0" dependencies: "@genql/runtime": "npm:^2.10.0" esbuild: "npm:^0.28.1" graphql: "npm:^16.8.1" lodash: "npm:^4.17.21" prettier: "npm:^3.8.3" - checksum: 10c0/740464acec94c1d4cc5fa50ed6b190a7f1d1681963f61437a6c704835c05ffe0f5a13e254e57601a99b07bbe0f68483dee19211731853aafbcc15ec79f8039ce + checksum: 10c0/eb222a726e21fc98f3a624f9acac7d47f0c769b989911889036665dac7c7c88698ec99beb844bf51a42e1696ea0a067bae8cf47fd751965af52a3720835242f0 languageName: node linkType: hard -"twenty-sdk@npm:2.13.0": - version: 2.13.0 - resolution: "twenty-sdk@npm:2.13.0" +"twenty-sdk@npm:^2.14.0": + version: 2.14.0 + resolution: "twenty-sdk@npm:2.14.0" dependencies: "@sniptt/guards": "npm:^0.2.0" axios: "npm:^1.16.0" @@ -2576,12 +2678,12 @@ __metadata: react: "npm:^19.2.0" react-dom: "npm:^19.2.0" tinyglobby: "npm:^0.2.15" - twenty-client-sdk: "npm:2.13.0" + twenty-client-sdk: "npm:2.14.0" typescript: "npm:^5.9.3" uuid: "npm:^13.0.2" bin: twenty: dist/cli.cjs - checksum: 10c0/51c350abe5d344fcad3c961a77632fd3cb2d91e357d0562b3eb02f05f66dbc41221c5463d0f91c022316a16af03a05ad43f038d9534c0ae31c060008d0e48672 + checksum: 10c0/c4211772f8dd2ba81074a74be5f616c4c264d6bf8a174c5724be6d1c9a8cb3ca68a2674653bb58c059dae4bf1b0f43f91991e73d6a01395807206f0ac44afb08 languageName: node linkType: hard @@ -2592,11 +2694,14 @@ __metadata: "@slack/web-api": "npm:^7.8.0" "@types/node": "npm:^24.7.2" "@types/react": "npm:^18.2.0" + "@typescript/native-preview": "npm:^7.0.0-dev.20260116.1" oxlint: "npm:^0.16.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" - twenty-sdk: "npm:2.13.0" + twenty-client-sdk: "npm:^2.14.0" + twenty-sdk: "npm:^2.14.0" typescript: "npm:^5.9.3" + vite-tsconfig-paths: "npm:^4.2.1" vitest: "npm:^4.0.0" languageName: unknown linkType: soft @@ -2674,6 +2779,22 @@ __metadata: languageName: node linkType: hard +"vite-tsconfig-paths@npm:^4.2.1": + version: 4.3.2 + resolution: "vite-tsconfig-paths@npm:4.3.2" + dependencies: + debug: "npm:^4.1.1" + globrex: "npm:^0.1.2" + tsconfck: "npm:^3.0.3" + peerDependencies: + vite: "*" + peerDependenciesMeta: + vite: + optional: true + checksum: 10c0/f390ac1d1c3992fc5ac50f9274c1090f8b55ab34a89ea88893db9a6924a3b26c9f64bc1163615150ad100749db73b6b2cf1d57f6cd60df6e762ceb5b8ad30024 + languageName: node + linkType: hard + "vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": version: 8.0.16 resolution: "vite@npm:8.0.16" diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx index 1129ff82e8..a5de428fd9 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx @@ -349,6 +349,32 @@ export default defineLogicFunction({ }); ``` +To declare your parameters **once** and serve both surfaces, define a single JSON Schema (`InputJsonSchema`) and convert it for the workflow action with `jsonSchemaToInputSchema` from `twenty-sdk/logic-function`. `toolTriggerSettings.inputSchema` takes the JSON Schema directly, while `workflowActionTriggerSettings.inputSchema` expects Twenty's `InputSchema`: + +```ts +import { defineLogicFunction } from 'twenty-sdk/define'; +import { jsonSchemaToInputSchema, type InputJsonSchema } from 'twenty-sdk/logic-function'; + +const inputSchema: InputJsonSchema = { + type: 'object', + properties: { + companyName: { type: 'string', label: 'Company name' }, + domain: { type: 'string', label: 'Domain' }, + }, + required: ['companyName'], +}; + +export default defineLogicFunction({ + ..., + toolTriggerSettings: { inputSchema }, + workflowActionTriggerSettings: { + label: 'Enrich Company', + icon: 'IconBuilding', + inputSchema: jsonSchemaToInputSchema(inputSchema), + }, +}); +``` + **Write a good `description`.** AI agents rely on the function's `description` field to decide when to use the tool. Be specific about what the tool does and when it should be called. @@ -356,6 +382,20 @@ export default defineLogicFunction({ + +**Runtime helpers.** `twenty-sdk/utils` re-exports small runtime helpers so handlers never import from `twenty-shared` directly. For example, `isDefined(value)` returns `false` for both `null` and `undefined` — use it to safely narrow optional handler inputs, which can arrive as `null` at runtime even when typed `T | undefined`: + +```ts +import { isDefined } from 'twenty-sdk/utils'; + +const handler = async (params: { parentMessageId?: string }) => { + if (isDefined(params.parentMessageId)) { + // params.parentMessageId is narrowed to string here + } +}; +``` + + **Install hooks** — pre-install and post-install handlers — share this runtime but are declared with their own define functions and don't take trigger settings. See [Install Hooks](/developers/extend/apps/config/install-hooks) for `definePreInstallLogicFunction` and `definePostInstallLogicFunction`. diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts index b29b72288f..9a733980f3 100644 --- a/packages/twenty-sdk/src/sdk/logic-function/index.ts +++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts @@ -36,7 +36,10 @@ export type { export type { RoutePayload } from '@/sdk/define/logic-functions/triggers/route-payload-type'; -export type { InputJsonSchema } from 'twenty-shared/logic-function'; +export { + jsonSchemaToInputSchema, + type InputJsonSchema, +} from 'twenty-shared/logic-function'; export { getConnection } from '@/sdk/logic-function/connections/get-connection'; export { listConnections } from '@/sdk/logic-function/connections/list-connections'; diff --git a/packages/twenty-sdk/src/sdk/utils/index.ts b/packages/twenty-sdk/src/sdk/utils/index.ts index 0b771dc49e..539fee9530 100644 --- a/packages/twenty-sdk/src/sdk/utils/index.ts +++ b/packages/twenty-sdk/src/sdk/utils/index.ts @@ -1 +1,2 @@ export { getPublicAssetUrl } from '@/sdk/utils/get-public-asset-url'; +export { isDefined } from 'twenty-shared/utils';