diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 720c883fc9..35549ee0bd 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -2989,6 +2989,11 @@ type WorkspaceAiStats { toolsCount: Int! } +type EnqueueJobResult { + enqueued: Boolean! + logicFunctionUniversalIdentifier: String! +} + type AppKeyValue { key: String! value: JSON @@ -3543,6 +3548,7 @@ type Mutation { updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel! setAppKeyValue(input: SetAppKeyValueInput!): AppKeyValue! deleteAppKeyValue(key: String!, scope: AppKeyValueScope = WORKSPACE): Boolean! + enqueueJob(input: EnqueueJobInput!): EnqueueJobResult! createChatThread: AgentChatThread! sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult! retryChatMessage(threadId: UUID!, modelId: String): SendChatMessageResult! @@ -4691,6 +4697,13 @@ input SetAppKeyValueInput { scope: AppKeyValueScope = WORKSPACE } +input EnqueueJobInput { + logicFunctionUniversalIdentifier: String! + payload: JSON + retryLimit: Int + delayMs: Int +} + input FileAttachmentInput { id: UUID! filename: String! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index cce8ca9e1f..d9333e73ff 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2682,6 +2682,12 @@ export interface WorkspaceAiStats { __typename: 'WorkspaceAiStats' } +export interface EnqueueJobResult { + enqueued: Scalars['Boolean'] + logicFunctionUniversalIdentifier: Scalars['String'] + __typename: 'EnqueueJobResult' +} + export interface AppKeyValue { key: Scalars['String'] value?: Scalars['JSON'] @@ -3062,6 +3068,7 @@ export interface Mutation { updateCalendarChannel: CalendarChannel setAppKeyValue: AppKeyValue deleteAppKeyValue: Scalars['Boolean'] + enqueueJob: EnqueueJobResult createChatThread: AgentChatThread sendChatMessage: SendChatMessageResult retryChatMessage: SendChatMessageResult @@ -5964,6 +5971,13 @@ export interface WorkspaceAiStatsGenqlSelection{ __scalar?: boolean | number } +export interface EnqueueJobResultGenqlSelection{ + enqueued?: boolean | number + logicFunctionUniversalIdentifier?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + export interface AppKeyValueGenqlSelection{ key?: boolean | number value?: boolean | number @@ -6376,6 +6390,7 @@ export interface MutationGenqlSelection{ updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} }) setAppKeyValue?: (AppKeyValueGenqlSelection & { __args: {input: SetAppKeyValueInput} }) deleteAppKeyValue?: { __args: {key: Scalars['String'], scope?: (AppKeyValueScope | null)} } + enqueueJob?: (EnqueueJobResultGenqlSelection & { __args: {input: EnqueueJobInput} }) createChatThread?: AgentChatThreadGenqlSelection sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} }) retryChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], modelId?: (Scalars['String'] | null)} }) @@ -6805,6 +6820,8 @@ export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChanne export interface SetAppKeyValueInput {key: Scalars['String'],value?: (Scalars['JSON'] | null),scope?: (AppKeyValueScope | null)} +export interface EnqueueJobInput {logicFunctionUniversalIdentifier: Scalars['String'],payload?: (Scalars['JSON'] | null),retryLimit?: (Scalars['Int'] | null),delayMs?: (Scalars['Int'] | null)} + export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']} export interface AgentChatQuestionAnswerInput {questionIndex: Scalars['Int'],selectedOptionIndices: Scalars['Int'][],freeText?: (Scalars['String'] | null)} @@ -8891,6 +8908,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const EnqueueJobResult_possibleTypes: string[] = ['EnqueueJobResult'] + export const isEnqueueJobResult = (obj?: { __typename?: any } | null): obj is EnqueueJobResult => { + if (!obj?.__typename) throw new Error('__typename is missing in "isEnqueueJobResult"') + return EnqueueJobResult_possibleTypes.includes(obj.__typename) + } + + + const AppKeyValue_possibleTypes: string[] = ['AppKeyValue'] export const isAppKeyValue = (obj?: { __typename?: any } | null): obj is AppKeyValue => { if (!obj?.__typename) throw new Error('__typename is missing in "isAppKeyValue"') diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 8ae29a3172..74c3681805 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -76,18 +76,18 @@ export default { 292, 295, 332, - 337, - 339, + 338, 340, 341, 342, - 344, - 346, - 359, - 366, - 373, + 343, + 345, + 347, + 360, + 367, 374, - 505 + 375, + 507 ], "types": { "BillingProductDTO": { @@ -6007,6 +6007,17 @@ export default { 1 ] }, + "EnqueueJobResult": { + "enqueued": [ + 3 + ], + "logicFunctionUniversalIdentifier": [ + 1 + ], + "__typename": [ + 1 + ] + }, "AppKeyValue": { "key": [ 1 @@ -6015,7 +6026,7 @@ export default { 5 ], "scope": [ - 337 + 338 ], "__typename": [ 1 @@ -6030,19 +6041,19 @@ export default { 1 ], "syncStatus": [ - 339 - ], - "syncStage": [ 340 ], - "visibility": [ + "syncStage": [ 341 ], + "visibility": [ + 342 + ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 342 + 343 ], "isSyncEnabled": [ 3 @@ -6093,7 +6104,7 @@ export default { 1 ], "pendingSyncAction": [ - 344 + 345 ], "messageChannelId": [ 4 @@ -6111,7 +6122,7 @@ export default { "MessageFolderPendingSyncAction": {}, "CollectionHash": { "collectionName": [ - 346 + 347 ], "hash": [ 1 @@ -6175,13 +6186,13 @@ export default { }, "MinimalMetadata": { "objectMetadataItems": [ - 347 - ], - "views": [ 348 ], + "views": [ + 349 + ], "collectionHashes": [ - 345 + 346 ], "__typename": [ 1 @@ -6355,7 +6366,7 @@ export default { 7, { "input": [ - 351, + 352, "GetApiKeyInput!" ] } @@ -6458,7 +6469,7 @@ export default { 11, { "input": [ - 352, + 353, "AgentIdInput!" ] } @@ -6492,7 +6503,7 @@ export default { "CursorPaging!" ], "filter": [ - 353, + 354, "ObjectFilter!" ] } @@ -6501,7 +6512,7 @@ export default { 22, { "input": [ - 354, + 355, "LogicFunctionIdInput!" ] } @@ -6513,7 +6524,7 @@ export default { 5, { "input": [ - 354, + 355, "LogicFunctionIdInput!" ] } @@ -6522,7 +6533,7 @@ export default { 1, { "input": [ - 354, + 355, "LogicFunctionIdInput!" ] } @@ -6744,7 +6755,7 @@ export default { 286, { "input": [ - 355, + 356, "PreviewMessageCampaignAudienceInput!" ] } @@ -6753,7 +6764,7 @@ export default { 293, { "input": [ - 356, + 357, "FindMessageSuppressionsInput!" ] } @@ -6800,7 +6811,7 @@ export default { } ], "myMessageFolders": [ - 343, + 344, { "messageChannelId": [ 4 @@ -6808,7 +6819,7 @@ export default { } ], "myCalendarChannels": [ - 338, + 339, { "connectedAccountId": [ 4 @@ -6816,17 +6827,17 @@ export default { } ], "minimalMetadata": [ - 349 + 350 ], "appKeyValue": [ - 336, + 337, { "key": [ 1, "String!" ], "scope": [ - 337 + 338 ] } ], @@ -6834,7 +6845,7 @@ export default { 203, { "filter": [ - 357 + 358 ] } ], @@ -6965,7 +6976,7 @@ export default { 321, { "input": [ - 358, + 359, "EventLogQueryInput!" ] } @@ -6974,7 +6985,7 @@ export default { 315, { "input": [ - 362, + 363, "PieChartDataInput!" ] } @@ -6983,7 +6994,7 @@ export default { 313, { "input": [ - 363, + 364, "LineChartDataInput!" ] } @@ -6992,7 +7003,7 @@ export default { 310, { "input": [ - 364, + 365, "BarChartDataInput!" ] } @@ -7042,7 +7053,7 @@ export default { 269, { "input": [ - 365 + 366 ] } ], @@ -7071,10 +7082,10 @@ export default { }, "ObjectFilter": { "and": [ - 353 + 354 ], "or": [ - 353 + 354 ], "id": [ 33 @@ -7159,10 +7170,10 @@ export default { }, "EventLogQueryInput": { "table": [ - 359 + 360 ], "filters": [ - 360 + 361 ], "first": [ 30 @@ -7183,7 +7194,7 @@ export default { 1 ], "dateRange": [ - 361 + 362 ], "recordId": [ 1 @@ -7250,7 +7261,7 @@ export default { 1 ], "operationTypes": [ - 366 + 367 ], "__typename": [ 1 @@ -7262,7 +7273,7 @@ export default { 3, { "input": [ - 368, + 369, "AddQuerySubscriptionInput!" ] } @@ -7271,7 +7282,7 @@ export default { 3, { "input": [ - 369, + 370, "RemoveQueryFromEventStreamInput!" ] } @@ -7280,7 +7291,7 @@ export default { 161, { "inputs": [ - 370, + 371, "[CreateNavigationMenuItemInput!]!" ] } @@ -7289,7 +7300,7 @@ export default { 161, { "input": [ - 370, + 371, "CreateNavigationMenuItemInput!" ] } @@ -7298,7 +7309,7 @@ export default { 161, { "inputs": [ - 371, + 372, "[UpdateOneNavigationMenuItemInput!]!" ] } @@ -7307,7 +7318,7 @@ export default { 161, { "input": [ - 371, + 372, "UpdateOneNavigationMenuItemInput!" ] } @@ -7342,7 +7353,7 @@ export default { "Float!" ], "fileFolder": [ - 373, + 374, "FileFolder!" ], "fieldMetadataId": [ @@ -7381,7 +7392,7 @@ export default { 148, { "file": [ - 374, + 375, "Upload!" ] } @@ -7390,7 +7401,7 @@ export default { 148, { "file": [ - 374, + 375, "Upload!" ] } @@ -7399,7 +7410,7 @@ export default { 148, { "file": [ - 374, + 375, "Upload!" ] } @@ -7408,7 +7419,7 @@ export default { 148, { "file": [ - 374, + 375, "Upload!" ] } @@ -7417,7 +7428,7 @@ export default { 148, { "file": [ - 374, + 375, "Upload!" ] } @@ -7426,7 +7437,7 @@ export default { 148, { "file": [ - 374, + 375, "Upload!" ], "fieldMetadataId": [ @@ -7439,7 +7450,7 @@ export default { 148, { "file": [ - 374, + 375, "Upload!" ], "fieldMetadataUniversalIdentifier": [ @@ -7452,7 +7463,7 @@ export default { 56, { "input": [ - 375, + 376, "CreateViewFilterGroupInput!" ] } @@ -7465,7 +7476,7 @@ export default { "String!" ], "input": [ - 376, + 377, "UpdateViewFilterGroupInput!" ] } @@ -7492,7 +7503,7 @@ export default { 58, { "input": [ - 377, + 378, "CreateViewFilterInput!" ] } @@ -7501,7 +7512,7 @@ export default { 58, { "input": [ - 378, + 379, "UpdateViewFilterInput!" ] } @@ -7510,7 +7521,7 @@ export default { 58, { "input": [ - 380, + 381, "DeleteViewFilterInput!" ] } @@ -7519,7 +7530,7 @@ export default { 58, { "input": [ - 381, + 382, "DestroyViewFilterInput!" ] } @@ -7528,7 +7539,7 @@ export default { 64, { "input": [ - 382, + 383, "CreateViewInput!" ] } @@ -7541,7 +7552,7 @@ export default { "String!" ], "input": [ - 383, + 384, "UpdateViewInput!" ] } @@ -7568,7 +7579,7 @@ export default { 64, { "input": [ - 384, + 385, "UpsertViewWidgetInput!" ] } @@ -7577,7 +7588,7 @@ export default { 61, { "input": [ - 390, + 391, "CreateViewSortInput!" ] } @@ -7586,7 +7597,7 @@ export default { 61, { "input": [ - 391, + 392, "UpdateViewSortInput!" ] } @@ -7595,7 +7606,7 @@ export default { 3, { "input": [ - 393, + 394, "DeleteViewSortInput!" ] } @@ -7604,7 +7615,7 @@ export default { 3, { "input": [ - 394, + 395, "DestroyViewSortInput!" ] } @@ -7613,7 +7624,7 @@ export default { 54, { "input": [ - 395, + 396, "UpdateViewFieldInput!" ] } @@ -7622,7 +7633,7 @@ export default { 54, { "input": [ - 397, + 398, "CreateViewFieldInput!" ] } @@ -7631,7 +7642,7 @@ export default { 54, { "inputs": [ - 397, + 398, "[CreateViewFieldInput!]!" ] } @@ -7640,7 +7651,7 @@ export default { 54, { "input": [ - 398, + 399, "DeleteViewFieldInput!" ] } @@ -7649,7 +7660,7 @@ export default { 54, { "input": [ - 399, + 400, "DestroyViewFieldInput!" ] } @@ -7658,7 +7669,7 @@ export default { 63, { "input": [ - 400, + 401, "UpdateViewFieldGroupInput!" ] } @@ -7667,7 +7678,7 @@ export default { 63, { "input": [ - 402, + 403, "CreateViewFieldGroupInput!" ] } @@ -7676,7 +7687,7 @@ export default { 63, { "inputs": [ - 402, + 403, "[CreateViewFieldGroupInput!]!" ] } @@ -7685,7 +7696,7 @@ export default { 63, { "input": [ - 403, + 404, "DeleteViewFieldGroupInput!" ] } @@ -7694,7 +7705,7 @@ export default { 63, { "input": [ - 404, + 405, "DestroyViewFieldGroupInput!" ] } @@ -7703,7 +7714,7 @@ export default { 64, { "input": [ - 405, + 406, "UpsertFieldsWidgetInput!" ] } @@ -7712,7 +7723,7 @@ export default { 7, { "input": [ - 408, + 409, "CreateApiKeyInput!" ] } @@ -7721,7 +7732,7 @@ export default { 7, { "input": [ - 409, + 410, "UpdateApiKeyInput!" ] } @@ -7730,7 +7741,7 @@ export default { 7, { "input": [ - 410, + 411, "RevokeApiKeyInput!" ] } @@ -7885,7 +7896,7 @@ export default { 147, { "input": [ - 411, + 412, "CreateApprovedAccessDomainInput!" ] } @@ -7894,7 +7905,7 @@ export default { 3, { "input": [ - 412, + 413, "DeleteApprovedAccessDomainInput!" ] } @@ -7903,7 +7914,7 @@ export default { 147, { "input": [ - 413, + 414, "ValidateApprovedAccessDomainInput!" ] } @@ -7912,7 +7923,7 @@ export default { 121, { "input": [ - 414, + 415, "CreatePageLayoutTabInput!" ] } @@ -7925,7 +7936,7 @@ export default { "String!" ], "input": [ - 415, + 416, "UpdatePageLayoutTabInput!" ] } @@ -7943,7 +7954,7 @@ export default { 122, { "input": [ - 416, + 417, "CreatePageLayoutInput!" ] } @@ -7956,7 +7967,7 @@ export default { "String!" ], "input": [ - 417, + 418, "UpdatePageLayoutInput!" ] } @@ -7978,7 +7989,7 @@ export default { "String!" ], "input": [ - 418, + 419, "UpdatePageLayoutWithTabsInput!" ] } @@ -8014,7 +8025,7 @@ export default { 81, { "input": [ - 422, + 423, "CreatePageLayoutWidgetInput!" ] } @@ -8027,7 +8038,7 @@ export default { "String!" ], "input": [ - 423, + 424, "UpdatePageLayoutWidgetInput!" ] } @@ -8045,7 +8056,7 @@ export default { 11, { "input": [ - 424, + 425, "CreateAgentInput!" ] } @@ -8054,7 +8065,7 @@ export default { 11, { "input": [ - 425, + 426, "UpdateAgentInput!" ] } @@ -8063,7 +8074,7 @@ export default { 11, { "input": [ - 352, + 353, "AgentIdInput!" ] } @@ -8072,7 +8083,7 @@ export default { 28, { "input": [ - 426, + 427, "CreateOneObjectInput!" ] } @@ -8081,7 +8092,7 @@ export default { 28, { "input": [ - 428, + 429, "DeleteOneObjectInput!" ] } @@ -8090,7 +8101,7 @@ export default { 28, { "input": [ - 429, + 430, "UpdateOneObjectInput!" ] } @@ -8099,7 +8110,7 @@ export default { 26, { "input": [ - 431, + 432, "CreateOneIndexInput!" ] } @@ -8108,7 +8119,7 @@ export default { 26, { "input": [ - 434, + 435, "DeleteOneIndexInput!" ] } @@ -8117,7 +8128,7 @@ export default { 22, { "input": [ - 354, + 355, "LogicFunctionIdInput!" ] } @@ -8126,7 +8137,7 @@ export default { 22, { "input": [ - 435, + 436, "CreateLogicFunctionFromSourceInput!" ] } @@ -8135,7 +8146,7 @@ export default { 143, { "input": [ - 436, + 437, "ExecuteOneLogicFunctionInput!" ] } @@ -8144,7 +8155,7 @@ export default { 3, { "input": [ - 437, + 438, "UpdateLogicFunctionFromSourceInput!" ] } @@ -8153,7 +8164,7 @@ export default { 15, { "input": [ - 439, + 440, "CreateCommandMenuItemInput!" ] } @@ -8162,7 +8173,7 @@ export default { 15, { "input": [ - 440, + 441, "UpdateCommandMenuItemInput!" ] } @@ -8189,7 +8200,7 @@ export default { 14, { "input": [ - 441, + 442, "CreateFrontComponentInput!" ] } @@ -8198,7 +8209,7 @@ export default { 14, { "input": [ - 442, + 443, "UpdateFrontComponentInput!" ] } @@ -8216,7 +8227,7 @@ export default { 70, { "data": [ - 444, + 445, "ActivateWorkspaceInput!" ] } @@ -8225,7 +8236,7 @@ export default { 70, { "data": [ - 445, + 446, "UpdateWorkspaceInput!" ] } @@ -8256,7 +8267,7 @@ export default { 200, { "input": [ - 446, + 447, "CreateApplicationRegistrationInput!" ] } @@ -8265,7 +8276,7 @@ export default { 76, { "input": [ - 447, + 448, "UpdateApplicationRegistrationInput!" ] } @@ -8292,7 +8303,7 @@ export default { 2, { "input": [ - 449, + 450, "CreateApplicationRegistrationVariableInput!" ] } @@ -8301,7 +8312,7 @@ export default { 2, { "input": [ - 450, + 451, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8319,7 +8330,7 @@ export default { 76, { "file": [ - 374, + 375, "Upload!" ], "universalIdentifier": [ @@ -8381,7 +8392,7 @@ export default { "UUID!" ], "input": [ - 452, + 453, "UpdateApplicationInput!" ] } @@ -8402,7 +8413,7 @@ export default { 24, { "input": [ - 453, + 454, "CreateOneFieldMetadataInput!" ] } @@ -8411,7 +8422,7 @@ export default { 24, { "input": [ - 455, + 456, "UpdateOneFieldMetadataInput!" ] } @@ -8420,7 +8431,7 @@ export default { 24, { "input": [ - 457, + 458, "DeleteOneFieldInput!" ] } @@ -8429,7 +8440,7 @@ export default { 60, { "input": [ - 458, + 459, "CreateViewGroupInput!" ] } @@ -8438,7 +8449,7 @@ export default { 60, { "inputs": [ - 458, + 459, "[CreateViewGroupInput!]!" ] } @@ -8447,7 +8458,7 @@ export default { 60, { "input": [ - 459, + 460, "UpdateViewGroupInput!" ] } @@ -8456,7 +8467,7 @@ export default { 60, { "inputs": [ - 459, + 460, "[UpdateViewGroupInput!]!" ] } @@ -8465,7 +8476,7 @@ export default { 60, { "input": [ - 461, + 462, "DeleteViewGroupInput!" ] } @@ -8474,7 +8485,7 @@ export default { 60, { "input": [ - 462, + 463, "DestroyViewGroupInput!" ] } @@ -8496,7 +8507,7 @@ export default { 49, { "createRoleInput": [ - 463, + 464, "CreateRoleInput!" ] } @@ -8505,7 +8516,7 @@ export default { 49, { "updateRoleInput": [ - 464, + 465, "UpdateRoleInput!" ] } @@ -8523,7 +8534,7 @@ export default { 46, { "upsertObjectPermissionsInput": [ - 466, + 467, "UpsertObjectPermissionsInput!" ] } @@ -8532,7 +8543,7 @@ export default { 47, { "upsertPermissionFlagsInput": [ - 468, + 469, "UpsertPermissionFlagsInput!" ] } @@ -8541,7 +8552,7 @@ export default { 41, { "upsertFieldPermissionsInput": [ - 469, + 470, "UpsertFieldPermissionsInput!" ] } @@ -8550,7 +8561,7 @@ export default { 239, { "input": [ - 471, + 472, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -8581,7 +8592,7 @@ export default { 287, { "input": [ - 474, + 475, "SendEmailViaDomainInput!" ] } @@ -8590,7 +8601,7 @@ export default { 289, { "input": [ - 475, + 476, "SendMessageCampaignInput!" ] } @@ -8599,7 +8610,7 @@ export default { 287, { "input": [ - 476, + 477, "SendMessageCampaignTestInput!" ] } @@ -8608,7 +8619,7 @@ export default { 294, { "input": [ - 477, + 478, "CreateUnsubscribeTopicInput!" ] } @@ -8617,7 +8628,7 @@ export default { 294, { "input": [ - 478, + 479, "UpdateUnsubscribeTopicInput!" ] } @@ -8635,7 +8646,7 @@ export default { 277, { "input": [ - 479, + 480, "UpdateMessageChannelInput!" ] } @@ -8644,7 +8655,7 @@ export default { 285, { "input": [ - 481, + 482, "CreateEmailGroupChannelInput!" ] } @@ -8653,7 +8664,7 @@ export default { 277, { "input": [ - 482, + 483, "UpdateEmailGroupChannelInput!" ] } @@ -8671,7 +8682,7 @@ export default { 275, { "input": [ - 483, + 484, "CreateEmailingDomainInput!" ] } @@ -8707,7 +8718,7 @@ export default { 306, { "input": [ - 484, + 485, "RunAgentInput!" ] } @@ -8716,7 +8727,7 @@ export default { 303, { "input": [ - 485, + 486, "CreateWebhookInput!" ] } @@ -8725,7 +8736,7 @@ export default { 303, { "input": [ - 486, + 487, "UpdateWebhookInput!" ] } @@ -8740,37 +8751,37 @@ export default { } ], "updateMessageFolder": [ - 343, + 344, { "input": [ - 488, + 489, "UpdateMessageFolderInput!" ] } ], "updateMessageFolders": [ - 343, + 344, { "input": [ - 490, + 491, "UpdateMessageFoldersInput!" ] } ], "updateCalendarChannel": [ - 338, + 339, { "input": [ - 491, + 492, "UpdateCalendarChannelInput!" ] } ], "setAppKeyValue": [ - 336, + 337, { "input": [ - 493, + 494, "SetAppKeyValueInput!" ] } @@ -8783,7 +8794,16 @@ export default { "String!" ], "scope": [ - 337 + 338 + ] + } + ], + "enqueueJob": [ + 336, + { + "input": [ + 495, + "EnqueueJobInput!" ] } ], @@ -8812,7 +8832,7 @@ export default { 1 ], "fileAttachments": [ - 494, + 496, "[FileAttachmentInput!]" ] } @@ -8841,7 +8861,7 @@ export default { "UUID!" ], "answers": [ - 495, + 497, "[AgentChatQuestionAnswerInput!]!" ], "modelId": [ @@ -8919,7 +8939,7 @@ export default { 322, { "input": [ - 496, + 498, "CreateSkillInput!" ] } @@ -8928,7 +8948,7 @@ export default { 322, { "input": [ - 497, + 499, "UpdateSkillInput!" ] } @@ -8986,7 +9006,7 @@ export default { 252, { "input": [ - 498, + 500, "GetAuthorizationUrlForSSOInput!" ] } @@ -9152,7 +9172,7 @@ export default { 255, { "input": [ - 499 + 501 ] } ], @@ -9164,7 +9184,7 @@ export default { "String!" ], "file": [ - 374, + 375, "Upload!" ] } @@ -9316,7 +9336,7 @@ export default { 3, { "input": [ - 500, + 502, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -9350,7 +9370,7 @@ export default { 210, { "input": [ - 501, + 503, "SetupOIDCSsoInput!" ] } @@ -9359,7 +9379,7 @@ export default { 210, { "input": [ - 502, + 504, "SetupSAMLSsoInput!" ] } @@ -9368,7 +9388,7 @@ export default { 206, { "input": [ - 503, + 505, "DeleteSsoInput!" ] } @@ -9377,7 +9397,7 @@ export default { 207, { "input": [ - 504, + 506, "EditSsoInput!" ] } @@ -9406,7 +9426,7 @@ export default { 318, { "type": [ - 505, + 507, "AnalyticsType!" ], "name": [ @@ -9446,7 +9466,7 @@ export default { 308, { "input": [ - 506, + 508, "CreateCalendarEventInput!" ] } @@ -9455,7 +9475,7 @@ export default { 317, { "input": [ - 507, + 509, "SendEmailInput!" ] } @@ -9477,7 +9497,7 @@ export default { "String!" ], "connectionParameters": [ - 509, + 511, "EmailAccountConnectionParameters!" ], "id": [ @@ -9489,7 +9509,7 @@ export default { 170, { "input": [ - 511, + 513, "UpdateLabPublicFeatureFlagInput!" ] } @@ -9554,7 +9574,7 @@ export default { 272, { "file": [ - 374, + 375, "Upload!" ], "applicationUniversalIdentifier": [ @@ -9562,7 +9582,7 @@ export default { "String!" ], "fileFolder": [ - 373, + 374, "FileFolder!" ], "filePath": [ @@ -9667,7 +9687,7 @@ export default { 4 ], "update": [ - 372 + 373 ], "__typename": [ 1 @@ -9778,7 +9798,7 @@ export default { 4 ], "update": [ - 379 + 380 ], "__typename": [ 1 @@ -9949,20 +9969,20 @@ export default { 4 ], "view": [ - 385 - ], - "viewFields": [ 386 ], - "viewFilters": [ + "viewFields": [ 387 ], - "viewFilterGroups": [ + "viewFilters": [ 388 ], - "viewSorts": [ + "viewFilterGroups": [ 389 ], + "viewSorts": [ + 390 + ], "__typename": [ 1 ] @@ -10110,7 +10130,7 @@ export default { 4 ], "update": [ - 392 + 393 ], "__typename": [ 1 @@ -10148,7 +10168,7 @@ export default { 4 ], "update": [ - 396 + 397 ], "__typename": [ 1 @@ -10224,7 +10244,7 @@ export default { 4 ], "update": [ - 401 + 402 ], "__typename": [ 1 @@ -10288,10 +10308,10 @@ export default { 4 ], "groups": [ - 406 + 407 ], "fields": [ - 407 + 408 ], "__typename": [ 1 @@ -10311,7 +10331,7 @@ export default { 3 ], "fields": [ - 407 + 408 ], "__typename": [ 1 @@ -10479,7 +10499,7 @@ export default { 4 ], "tabs": [ - 419 + 420 ], "__typename": [ 1 @@ -10502,7 +10522,7 @@ export default { 85 ], "widgets": [ - 420 + 421 ], "__typename": [ 1 @@ -10525,7 +10545,7 @@ export default { 4 ], "gridPosition": [ - 421 + 422 ], "position": [ 5 @@ -10574,7 +10594,7 @@ export default { 4 ], "gridPosition": [ - 421 + 422 ], "position": [ 5 @@ -10600,7 +10620,7 @@ export default { 4 ], "gridPosition": [ - 421 + 422 ], "position": [ 5 @@ -10693,7 +10713,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 427 + 428 ], "__typename": [ 1 @@ -10753,7 +10773,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 430 + 431 ], "id": [ 4 @@ -10808,7 +10828,7 @@ export default { }, "CreateOneIndexInput": { "index": [ - 432 + 433 ], "__typename": [ 1 @@ -10819,7 +10839,7 @@ export default { 4 ], "fields": [ - 433 + 434 ], "indexType": [ 27 @@ -10904,7 +10924,7 @@ export default { 4 ], "update": [ - 438 + 439 ], "__typename": [ 1 @@ -11064,7 +11084,7 @@ export default { 4 ], "update": [ - 443 + 444 ], "__typename": [ 1 @@ -11191,7 +11211,7 @@ export default { 1 ], "update": [ - 448 + 449 ], "__typename": [ 1 @@ -11245,7 +11265,7 @@ export default { 1 ], "update": [ - 451 + 452 ], "__typename": [ 1 @@ -11275,7 +11295,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 454 + 455 ], "__typename": [ 1 @@ -11348,7 +11368,7 @@ export default { 4 ], "update": [ - 456 + 457 ], "__typename": [ 1 @@ -11443,7 +11463,7 @@ export default { 4 ], "update": [ - 460 + 461 ], "__typename": [ 1 @@ -11528,7 +11548,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 465 + 466 ], "id": [ 4 @@ -11583,7 +11603,7 @@ export default { 4 ], "objectPermissions": [ - 467 + 468 ], "__typename": [ 1 @@ -11625,7 +11645,7 @@ export default { 4 ], "fieldPermissions": [ - 470 + 471 ], "__typename": [ 1 @@ -11656,10 +11676,10 @@ export default { 4 ], "predicates": [ - 472 + 473 ], "predicateGroups": [ - 473 + 474 ], "__typename": [ 1 @@ -11813,7 +11833,7 @@ export default { 4 ], "update": [ - 480 + 481 ], "__typename": [ 1 @@ -11911,7 +11931,7 @@ export default { 4 ], "update": [ - 487 + 488 ], "__typename": [ 1 @@ -11939,7 +11959,7 @@ export default { 4 ], "update": [ - 489 + 490 ], "__typename": [ 1 @@ -11958,7 +11978,7 @@ export default { 4 ], "update": [ - 489 + 490 ], "__typename": [ 1 @@ -11969,7 +11989,7 @@ export default { 4 ], "update": [ - 492 + 493 ], "__typename": [ 1 @@ -11977,13 +11997,13 @@ export default { }, "UpdateCalendarChannelInputUpdates": { "visibility": [ - 341 + 342 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 342 + 343 ], "isSyncEnabled": [ 3 @@ -12000,7 +12020,24 @@ export default { 5 ], "scope": [ - 337 + 338 + ], + "__typename": [ + 1 + ] + }, + "EnqueueJobInput": { + "logicFunctionUniversalIdentifier": [ + 1 + ], + "payload": [ + 5 + ], + "retryLimit": [ + 30 + ], + "delayMs": [ + 30 ], "__typename": [ 1 @@ -12237,7 +12274,7 @@ export default { 1 ], "files": [ - 508 + 510 ], "__typename": [ 1 @@ -12259,13 +12296,13 @@ export default { 1 ], "IMAP": [ - 510 + 512 ], "SMTP": [ - 510 + 512 ], "CALDAV": [ - 510 + 512 ], "__typename": [ 1 @@ -12316,7 +12353,7 @@ export default { 240, { "input": [ - 513, + 515, "LogicFunctionLogsInput!" ] } @@ -12334,7 +12371,7 @@ export default { 319, { "table": [ - 359, + 360, "EventLogTable!" ] } diff --git a/packages/twenty-docs/developers/extend/apps/logic/background-jobs.mdx b/packages/twenty-docs/developers/extend/apps/logic/background-jobs.mdx new file mode 100644 index 0000000000..f5b74c8d5c --- /dev/null +++ b/packages/twenty-docs/developers/extend/apps/logic/background-jobs.mdx @@ -0,0 +1,165 @@ +--- +title: Background Jobs +description: Hand long or rate-limited work to the Twenty workers by enqueuing another logic function run instead of doing everything inline. +icon: "layer-group" +--- + +A logic function run is capped by its `timeoutSeconds` (900 seconds maximum). Anything that can't finish in that window — a full re-sync, a per-record fan-out, a third-party API that rate-limits you — has to be split into smaller runs. + +`enqueueJob` does exactly that: it asks the Twenty workers to run one of your app's logic functions later, in its own process, with its own timeout budget. The caller returns immediately. + +```text + ┌─────────────────┐ enqueueJob(...) ┌──────────────┐ ┌────────────────────┐ + │ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │ + │ (returns now) │ │ (workers) │ │ (fresh run/timeout)│ + └─────────────────┘ └──────────────┘ └────────────────────┘ +``` + +## Enqueue a run + +Import `enqueueJob` from `twenty-sdk/logic-function` and point it at the `universalIdentifier` of the logic function you want to run. + +```ts src/logic-functions/sync-all-contacts.ts +import { enqueueJob } from 'twenty-sdk/logic-function'; + +await enqueueJob({ + logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33', + payload: { page: 1 }, +}); +``` + +The target function receives `payload` as its handler argument, exactly like any other trigger. It must belong to the **same application** as the caller — enqueuing another app's function is rejected with `Logic function not found`. + + +`enqueueJob` returns as soon as the job is accepted, not when it has run. It does not return the target's result — have the target write what it produces to the [key-value store](/developers/extend/apps/logic/key-value-store) or to a workspace record if you need to read it back. + + +## Job options + +| Option | Default | Range | What it does | +|--------|---------|-------|--------------| +| `retryLimit` | `0` | `0`–`10` | Extra attempts if the run throws. Only raise this for handlers that are safe to run twice. | +| `delayMs` | `0` | `0`–`604800000` (7 days) | Wait this long before the run becomes eligible. | + +```ts +await enqueueJob({ + logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33', + payload: { page: 1 }, + retryLimit: 3, + delayMs: 60_000, +}); +``` + + +**Priority is not configurable yet.** Enqueued jobs always run at the lowest priority, so platform work is never delayed behind application jobs. Control over priority is coming soon. + + +The queued run inherits the acting user of the function that enqueued it, so it acts with the same permissions. + +## Use it: page through a long sync + +The classic shape is a function that enqueues *itself* with the next cursor. Each run does one page of work well inside its own timeout, and the chain stops when there is nothing left. + +```ts src/logic-functions/sync-contacts-page.ts +import { defineLogicFunction } from 'twenty-sdk/define'; +import { enqueueJob } from 'twenty-sdk/logic-function'; + +const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33'; + +const handler = async (params: { cursor?: string }) => { + const { contacts, nextCursor } = await fetchContactsPage(params.cursor); + + await importContacts(contacts); + + if (nextCursor) { + await enqueueJob({ + logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE, + payload: { cursor: nextCursor }, + delayMs: 2_000, + }); + } + + return { imported: contacts.length, done: !nextCursor }; +}; + +export default defineLogicFunction({ + universalIdentifier: SYNC_CONTACTS_PAGE, + name: 'sync-contacts-page', + timeoutSeconds: 120, + handler, +}); +``` + +## Fan out per record + +When the work is naturally per-item, enqueue one job per item and let the workers process them in parallel instead of looping inline. + +```ts +const companies = await listCompaniesToEnrich(); + +await Promise.all( + companies.map((company) => + enqueueJob({ + logicFunctionUniversalIdentifier: ENRICH_COMPANY, + payload: { companyId: company.id }, + retryLimit: 2, + }), + ), +); +``` + +## Good practice for long-running work + +Two rules cover almost every long job: **recurse instead of looping**, and **process a bounded chunk per run**. + +A run that tries to do everything is the failure mode — it hits the timeout, and with a retry it starts the whole thing again from zero. Instead, size one chunk so it comfortably finishes inside `timeoutSeconds`, persist your position, and enqueue the next run. + +```ts src/logic-functions/enrich-companies-batch.ts +import { defineLogicFunction } from 'twenty-sdk/define'; +import { enqueueJob, kv } from 'twenty-sdk/logic-function'; + +const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10'; +const CHUNK_SIZE = 50; + +const handler = async (params: { offset?: number }) => { + const offset = params.offset ?? 0; + const companies = await listCompaniesToEnrich({ + offset, + limit: CHUNK_SIZE, + }); + + for (const company of companies) { + await enrichCompany(company); + } + + await kv.set('enrich:progress', { offset: offset + companies.length }); + + if (companies.length === CHUNK_SIZE) { + await enqueueJob({ + logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH, + payload: { offset: offset + CHUNK_SIZE }, + }); + } + + return { processed: companies.length, done: companies.length < CHUNK_SIZE }; +}; + +export default defineLogicFunction({ + universalIdentifier: ENRICH_COMPANIES_BATCH, + name: 'enrich-companies-batch', + timeoutSeconds: 300, + handler, +}); +``` + +What makes this hold up: + +- **Size the chunk from the slowest item, not the average.** `CHUNK_SIZE × worst-case item time` has to fit in `timeoutSeconds` with room to spare, or the tail of a chunk is lost when the run is cut off. +- **Make the terminating condition explicit.** Recurse only while a full chunk came back. A chain that stops on "no results" alone will keep going forever if the source ever returns a short page mid-way. +- **Persist progress before enqueuing the next run,** so a failed link restarts from the last completed chunk instead of the beginning. +- **Keep each chunk idempotent.** Reprocessing one chunk after a retry must not double-write — key writes on the record or external id you are processing. +- **Prefer a chunked chain over one giant fan-out** when the work hits a rate-limited third party: a chain with `delayMs` paces itself, whereas thousands of jobs enqueued at once all become eligible immediately. + + +Retries re-run the whole handler. Keep enqueued handlers idempotent before setting `retryLimit` above `0`. + diff --git a/packages/twenty-docs/developers/extend/apps/logic/overview.mdx b/packages/twenty-docs/developers/extend/apps/logic/overview.mdx index f4ac0a1b20..2f7d841a64 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/overview.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/overview.mdx @@ -37,6 +37,9 @@ A Twenty app's **logic layer** is the code that *runs* — server-side TypeScrip Persist state between logic function runs — caches, cursors, and cross-workspace claims. + + Enqueue a logic function run on the workers to get past the per-run timeout. + ## Trigger types at a glance diff --git a/packages/twenty-docs/docs.json b/packages/twenty-docs/docs.json index d1b33d05e4..749d9216d2 100644 --- a/packages/twenty-docs/docs.json +++ b/packages/twenty-docs/docs.json @@ -436,6 +436,7 @@ "developers/extend/apps/logic/overview", "developers/extend/apps/logic/logic-functions", "developers/extend/apps/logic/key-value-store", + "developers/extend/apps/logic/background-jobs", "developers/extend/apps/logic/skills-and-agents", "developers/extend/apps/logic/connections" ] diff --git a/packages/twenty-docs/navigation/base-structure.json b/packages/twenty-docs/navigation/base-structure.json index 22b826962c..bb6aa46d6c 100644 --- a/packages/twenty-docs/navigation/base-structure.json +++ b/packages/twenty-docs/navigation/base-structure.json @@ -437,6 +437,7 @@ "developers/extend/apps/logic/overview", "developers/extend/apps/logic/logic-functions", "developers/extend/apps/logic/key-value-store", + "developers/extend/apps/logic/background-jobs", "developers/extend/apps/logic/skills-and-agents", "developers/extend/apps/logic/connections" ] diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 894def3e48..d3e795cf45 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1680,6 +1680,19 @@ export enum EngineComponentKey { VIEW_PREVIOUS_AI_CHATS = 'VIEW_PREVIOUS_AI_CHATS' } +export type EnqueueJobInput = { + delayMs?: InputMaybe; + logicFunctionUniversalIdentifier: Scalars['String']['input']; + payload?: InputMaybe; + retryLimit?: InputMaybe; +}; + +export type EnqueueJobResult = { + __typename?: 'EnqueueJobResult'; + enqueued: Scalars['Boolean']['output']; + logicFunctionUniversalIdentifier: Scalars['String']['output']; +}; + export type EnterpriseLicenseInfoDto = { __typename?: 'EnterpriseLicenseInfoDTO'; expiresAt?: Maybe; @@ -2673,6 +2686,7 @@ export type Mutation = { editSSOIdentityProvider: EditSso; emailPasswordResetLink: EmailPasswordResetLink; endSubscriptionTrialPeriod: BillingEndTrialPeriod; + enqueueJob: EnqueueJobResult; enrichWorkspaceCompany: WorkspaceCompanyEnrichmentResult; evaluateAgentTurn: AgentTurnEvaluation; executeOneLogicFunction: LogicFunctionExecutionResult; @@ -3328,6 +3342,11 @@ export type MutationEmailPasswordResetLinkArgs = { }; +export type MutationEnqueueJobArgs = { + input: EnqueueJobInput; +}; + + export type MutationEvaluateAgentTurnArgs = { turnId: Scalars['UUID']['input']; }; diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts index 690891810d..70dc883d54 100644 --- a/packages/twenty-sdk/src/sdk/logic-function/index.ts +++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts @@ -58,6 +58,13 @@ export type { AppConnection } from '@/sdk/logic-function/connections/types/app-c export { runAgent } from '@/sdk/logic-function/agents/run-agent'; export type { RunAgentInput, RunAgentResult } from 'twenty-shared/application'; +export { enqueueJob } from '@/sdk/logic-function/jobs/enqueue-job'; +export type { + EnqueueJobInput, + EnqueueJobOptions, + EnqueueJobResult, +} from 'twenty-shared/application'; + export { kv } from '@/sdk/logic-function/key-value/kv'; export type { AppKeyValue, AppKeyValueScope } from 'twenty-shared/application'; diff --git a/packages/twenty-sdk/src/sdk/logic-function/jobs/__tests__/enqueue-job.spec.ts b/packages/twenty-sdk/src/sdk/logic-function/jobs/__tests__/enqueue-job.spec.ts new file mode 100644 index 0000000000..3baf2842e2 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/jobs/__tests__/enqueue-job.spec.ts @@ -0,0 +1,116 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; + +const TARGET_UNIVERSAL_IDENTIFIER = '5a2f4d2a-1a1e-4c66-8a54-1f0a2b3c4d5e'; + +const importEnqueueJob = async () => { + const module = await import('@/sdk/logic-function/jobs/enqueue-job'); + + return module.enqueueJob; +}; + +const graphqlResponse = (data: unknown) => + new Response(JSON.stringify({ data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + +const successResponse = () => + graphqlResponse({ + enqueueJob: { + enqueued: true, + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }, + }); + +describe('enqueueJob', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + vi.resetModules(); + process.env.TWENTY_API_URL = 'https://api.test'; + process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token'; + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + delete process.env.TWENTY_API_URL; + delete process.env.TWENTY_APP_ACCESS_TOKEN; + fetchSpy.mockRestore(); + }); + + it('calls the enqueueJob mutation on the metadata API and returns its result', async () => { + fetchSpy.mockResolvedValue(successResponse()); + + const enqueueJob = await importEnqueueJob(); + + const result = await enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + payload: { batchIndex: 2 }, + retryLimit: 3, + delayMs: 1000, + }); + + expect(result).toEqual({ + enqueued: true, + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }); + + const [url, requestInit] = fetchSpy.mock.calls[0]; + + expect(url).toBe('https://api.test/metadata'); + + const sentBody = JSON.parse(requestInit?.body as string); + + expect(sentBody.query).toContain( + 'enqueueJob(input:$v1){enqueued,logicFunctionUniversalIdentifier}', + ); + expect(Object.values(sentBody.variables)).toEqual([ + { + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + payload: { batchIndex: 2 }, + retryLimit: 3, + delayMs: 1000, + }, + ]); + }); + + it('sends the app access token as a bearer credential', async () => { + fetchSpy.mockResolvedValue(successResponse()); + + const enqueueJob = await importEnqueueJob(); + + await enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }); + + const [, requestInit] = fetchSpy.mock.calls[0]; + const headers = new Headers(requestInit?.headers); + + expect(headers.get('authorization')).toBe('Bearer app-token'); + }); + + it('surfaces GraphQL errors as a rejection', async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ errors: [{ message: 'Logic function not found' }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const enqueueJob = await importEnqueueJob(); + + await expect( + enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }), + ).rejects.toThrow(/Logic function not found/); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/logic-function/jobs/enqueue-job.ts b/packages/twenty-sdk/src/sdk/logic-function/jobs/enqueue-job.ts new file mode 100644 index 0000000000..e33d8f0493 --- /dev/null +++ b/packages/twenty-sdk/src/sdk/logic-function/jobs/enqueue-job.ts @@ -0,0 +1,21 @@ +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; +import { + type EnqueueJobInput, + type EnqueueJobResult, +} from 'twenty-shared/application'; + +export const enqueueJob = async ( + input: EnqueueJobInput, +): Promise => { + const client = new MetadataApiClient(); + + const { enqueueJob: result } = await client.mutation({ + enqueueJob: { + __args: { input }, + enqueued: true, + logicFunctionUniversalIdentifier: true, + }, + }); + + return result; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-job/application-job.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-job/application-job.module.ts new file mode 100644 index 0000000000..fc5a4de829 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-job/application-job.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { ApplicationJobResolver } from 'src/engine/core-modules/application/application-job/application-job.resolver'; +import { ApplicationJobService } from 'src/engine/core-modules/application/application-job/services/application-job.service'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; + +@Module({ + imports: [WorkspaceCacheModule], + providers: [ApplicationJobService, ApplicationJobResolver], + exports: [ApplicationJobService], +}) +export class ApplicationJobModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-job/application-job.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-job/application-job.resolver.ts new file mode 100644 index 0000000000..8dd488fea6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-job/application-job.resolver.ts @@ -0,0 +1,46 @@ +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Args, Mutation } from '@nestjs/graphql'; + +import { type EnqueueJobResult } from 'twenty-shared/application'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter'; +import { EnqueueJobResultDTO } from 'src/engine/core-modules/application/application-job/dtos/enqueue-job-result.dto'; +import { EnqueueJobInputDTO } from 'src/engine/core-modules/application/application-job/dtos/enqueue-job.input'; +import { ApplicationJobService } from 'src/engine/core-modules/application/application-job/services/application-job.service'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; +import { AuthApplication } from 'src/engine/decorators/auth/auth-application.decorator'; +import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@UseGuards(WorkspaceAuthGuard, NoPermissionGuard) +@UseFilters(ApplicationExceptionFilter) +@UsePipes(ResolverValidationPipe) +@MetadataResolver() +export class ApplicationJobResolver { + constructor(private readonly applicationJobService: ApplicationJobService) {} + + @Mutation(() => EnqueueJobResultDTO) + async enqueueJob( + @AuthApplication() application: FlatApplication, + @AuthWorkspace() workspace: FlatWorkspace, + @AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined, + @AuthUserWorkspaceId({ allowUndefined: true }) + userWorkspaceId: string | undefined, + @Args('input') input: EnqueueJobInputDTO, + ): Promise { + return this.applicationJobService.enqueueJob({ + applicationId: application.id, + workspaceId: workspace.id, + userId: user?.id ?? null, + userWorkspaceId: userWorkspaceId ?? null, + input, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-job/constants/enqueue-job.constant.ts b/packages/twenty-server/src/engine/core-modules/application/application-job/constants/enqueue-job.constant.ts new file mode 100644 index 0000000000..547ba74100 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-job/constants/enqueue-job.constant.ts @@ -0,0 +1,8 @@ +export const ENQUEUE_JOB_MIN_RETRY_LIMIT = 0; +export const ENQUEUE_JOB_MAX_RETRY_LIMIT = 10; +export const ENQUEUE_JOB_DEFAULT_RETRY_LIMIT = 0; + +export const ENQUEUE_JOB_PRIORITY = 10; + +export const ENQUEUE_JOB_MIN_DELAY_MS = 0; +export const ENQUEUE_JOB_MAX_DELAY_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-job/dtos/enqueue-job-result.dto.ts b/packages/twenty-server/src/engine/core-modules/application/application-job/dtos/enqueue-job-result.dto.ts new file mode 100644 index 0000000000..541f70a436 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-job/dtos/enqueue-job-result.dto.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { type EnqueueJobResult } from 'twenty-shared/application'; + +@ObjectType('EnqueueJobResult') +export class EnqueueJobResultDTO implements EnqueueJobResult { + @Field() + enqueued: boolean; + + @Field() + logicFunctionUniversalIdentifier: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-job/dtos/enqueue-job.input.ts b/packages/twenty-server/src/engine/core-modules/application/application-job/dtos/enqueue-job.input.ts new file mode 100644 index 0000000000..0cdccd41ba --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-job/dtos/enqueue-job.input.ts @@ -0,0 +1,47 @@ +import { Field, InputType, Int } from '@nestjs/graphql'; + +import { + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsUUID, + Max, + Min, +} from 'class-validator'; +import GraphQLJSON from 'graphql-type-json'; +import { type EnqueueJobInput } from 'twenty-shared/application'; + +import { + ENQUEUE_JOB_MAX_DELAY_MS, + ENQUEUE_JOB_MAX_RETRY_LIMIT, + ENQUEUE_JOB_MIN_DELAY_MS, + ENQUEUE_JOB_MIN_RETRY_LIMIT, +} from 'src/engine/core-modules/application/application-job/constants/enqueue-job.constant'; + +@InputType('EnqueueJobInput') +export class EnqueueJobInputDTO implements EnqueueJobInput { + @IsUUID() + @IsNotEmpty() + @Field() + logicFunctionUniversalIdentifier: string; + + @IsObject() + @IsOptional() + @Field(() => GraphQLJSON, { nullable: true }) + payload?: Record; + + @IsInt() + @Min(ENQUEUE_JOB_MIN_RETRY_LIMIT) + @Max(ENQUEUE_JOB_MAX_RETRY_LIMIT) + @IsOptional() + @Field(() => Int, { nullable: true }) + retryLimit?: number; + + @IsInt() + @Min(ENQUEUE_JOB_MIN_DELAY_MS) + @Max(ENQUEUE_JOB_MAX_DELAY_MS) + @IsOptional() + @Field(() => Int, { nullable: true }) + delayMs?: number; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-job/services/__tests__/application-job.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-job/services/__tests__/application-job.service.spec.ts new file mode 100644 index 0000000000..ab28084a80 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-job/services/__tests__/application-job.service.spec.ts @@ -0,0 +1,200 @@ +import { ENQUEUE_JOB_PRIORITY } from 'src/engine/core-modules/application/application-job/constants/enqueue-job.constant'; +import { type EnqueueJobInputDTO } from 'src/engine/core-modules/application/application-job/dtos/enqueue-job.input'; +import { ApplicationJobService } from 'src/engine/core-modules/application/application-job/services/application-job.service'; +import { + ApplicationException, + ApplicationExceptionCode, +} from 'src/engine/core-modules/application/application.exception'; +import { LogicFunctionTriggerJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job'; +import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +const APPLICATION_ID = 'c2a9e9d0-1f42-4f0e-9a0e-6d2e4b2a1f01'; +const WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419'; +const TARGET_UNIVERSAL_IDENTIFIER = '5a2f4d2a-1a1e-4c66-8a54-1f0a2b3c4d5e'; +const TARGET_LOGIC_FUNCTION_ID = 'ab6a2e5c-8c1f-4d0a-9bd1-52c1f5a9e100'; + +const buildFlatLogicFunction = ( + overrides: Partial<{ + applicationId: string; + deletedAt: Date | null; + }> = {}, +) => ({ + id: TARGET_LOGIC_FUNCTION_ID, + universalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + applicationId: APPLICATION_ID, + deletedAt: null, + ...overrides, +}); + +describe('ApplicationJobService', () => { + let service: ApplicationJobService; + let workspaceCacheService: jest.Mocked< + Pick + >; + let messageQueueService: jest.Mocked>; + + const setCachedLogicFunctions = ( + flatLogicFunctions: ReturnType[], + ) => { + workspaceCacheService.getOrRecompute.mockResolvedValue({ + flatLogicFunctionMaps: { + byUniversalIdentifier: Object.fromEntries( + flatLogicFunctions.map((flatLogicFunction) => [ + flatLogicFunction.universalIdentifier, + flatLogicFunction, + ]), + ), + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + }; + + const enqueueJob = ( + input: EnqueueJobInputDTO, + overrides: { userId?: string | null; userWorkspaceId?: string | null } = {}, + ) => + service.enqueueJob({ + applicationId: APPLICATION_ID, + workspaceId: WORKSPACE_ID, + userId: overrides.userId ?? null, + userWorkspaceId: overrides.userWorkspaceId ?? null, + input, + }); + + beforeEach(() => { + jest.clearAllMocks(); + + workspaceCacheService = { getOrRecompute: jest.fn() }; + setCachedLogicFunctions([buildFlatLogicFunction()]); + messageQueueService = { add: jest.fn().mockResolvedValue(undefined) }; + + service = new ApplicationJobService( + workspaceCacheService as unknown as WorkspaceCacheService, + messageQueueService as unknown as MessageQueueService, + ); + }); + + it('should enqueue a logic function trigger job with the provided job options', async () => { + const result = await enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + payload: { batchIndex: 2 }, + retryLimit: 3, + delayMs: 1000, + }); + + expect(result).toEqual({ + enqueued: true, + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }); + + expect(messageQueueService.add).toHaveBeenCalledWith( + LogicFunctionTriggerJob.name, + { + logicFunctionId: TARGET_LOGIC_FUNCTION_ID, + workspaceId: WORKSPACE_ID, + payload: { batchIndex: 2 }, + }, + { retryLimit: 3, priority: ENQUEUE_JOB_PRIORITY, delay: 1000 }, + ); + }); + + it('should always enqueue at the lowest priority so platform jobs go first', async () => { + await enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }); + + expect(messageQueueService.add).toHaveBeenCalledWith( + LogicFunctionTriggerJob.name, + expect.anything(), + expect.objectContaining({ priority: ENQUEUE_JOB_PRIORITY }), + ); + }); + + it('should default to no retry and omit unset queue options', async () => { + await enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }); + + expect(messageQueueService.add).toHaveBeenCalledWith( + LogicFunctionTriggerJob.name, + { + logicFunctionId: TARGET_LOGIC_FUNCTION_ID, + workspaceId: WORKSPACE_ID, + payload: {}, + }, + { retryLimit: 0, priority: ENQUEUE_JOB_PRIORITY }, + ); + }); + + it('should forward the acting user so the queued run keeps the caller permissions', async () => { + await enqueueJob( + { logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER }, + { userId: 'user-1', userWorkspaceId: 'user-workspace-1' }, + ); + + expect(messageQueueService.add).toHaveBeenCalledWith( + LogicFunctionTriggerJob.name, + expect.objectContaining({ + userId: 'user-1', + userWorkspaceId: 'user-workspace-1', + }), + expect.anything(), + ); + }); + + it('should resolve the target from the workspace cache instead of the database', async () => { + await enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }); + + expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith( + WORKSPACE_ID, + ['flatLogicFunctionMaps'], + ); + }); + + it('should throw LOGIC_FUNCTION_NOT_FOUND when the function belongs to another application', async () => { + setCachedLogicFunctions([ + buildFlatLogicFunction({ applicationId: 'another-application-id' }), + ]); + + await expect( + enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }), + ).rejects.toMatchObject({ + code: ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + }); + + expect(messageQueueService.add).not.toHaveBeenCalled(); + }); + + it('should throw LOGIC_FUNCTION_NOT_FOUND when the function is soft deleted', async () => { + setCachedLogicFunctions([ + buildFlatLogicFunction({ deletedAt: new Date('2026-01-01') }), + ]); + + await expect( + enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }), + ).rejects.toMatchObject({ + code: ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + }); + + expect(messageQueueService.add).not.toHaveBeenCalled(); + }); + + it('should throw LOGIC_FUNCTION_NOT_FOUND when the function is unknown', async () => { + setCachedLogicFunctions([]); + + await expect( + enqueueJob({ + logicFunctionUniversalIdentifier: TARGET_UNIVERSAL_IDENTIFIER, + }), + ).rejects.toThrow(ApplicationException); + + expect(messageQueueService.add).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-job/services/application-job.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-job/services/application-job.service.ts new file mode 100644 index 0000000000..39767c7e0f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-job/services/application-job.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; + +import { type EnqueueJobResult } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +import { + ENQUEUE_JOB_DEFAULT_RETRY_LIMIT, + ENQUEUE_JOB_PRIORITY, +} from 'src/engine/core-modules/application/application-job/constants/enqueue-job.constant'; +import { type EnqueueJobInputDTO } from 'src/engine/core-modules/application/application-job/dtos/enqueue-job.input'; +import { + ApplicationException, + ApplicationExceptionCode, +} from 'src/engine/core-modules/application/application.exception'; +import { + LogicFunctionTriggerJob, + type LogicFunctionTriggerJobData, +} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +@Injectable() +export class ApplicationJobService { + constructor( + private readonly workspaceCacheService: WorkspaceCacheService, + @InjectMessageQueue(MessageQueue.logicFunctionQueue) + private readonly messageQueueService: MessageQueueService, + ) {} + + async enqueueJob({ + applicationId, + workspaceId, + userId, + userWorkspaceId, + input, + }: { + applicationId: string; + workspaceId: string; + userId: string | null; + userWorkspaceId: string | null; + input: EnqueueJobInputDTO; + }): Promise { + const { logicFunctionUniversalIdentifier } = input; + + const { flatLogicFunctionMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatLogicFunctionMaps', + ]); + + const flatLogicFunction = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatLogicFunctionMaps, + universalIdentifier: logicFunctionUniversalIdentifier, + }); + + if ( + !isDefined(flatLogicFunction) || + isDefined(flatLogicFunction.deletedAt) || + flatLogicFunction.applicationId !== applicationId + ) { + throw new ApplicationException( + `Logic function ${logicFunctionUniversalIdentifier} not found in this application`, + ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + + await this.messageQueueService.add( + LogicFunctionTriggerJob.name, + { + logicFunctionId: flatLogicFunction.id, + workspaceId, + payload: input.payload ?? {}, + ...(isDefined(userId) ? { userId } : {}), + ...(isDefined(userWorkspaceId) ? { userWorkspaceId } : {}), + }, + { + retryLimit: input.retryLimit ?? ENQUEUE_JOB_DEFAULT_RETRY_LIMIT, + priority: ENQUEUE_JOB_PRIORITY, + ...(isDefined(input.delayMs) ? { delay: input.delayMs } : {}), + }, + ); + + return { enqueued: true, logicFunctionUniversalIdentifier }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index a80a88fa44..969dc6a6d2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -7,6 +7,7 @@ import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.mod import { AiGenerateTextModule } from 'src/engine/metadata-modules/ai/ai-generate-text/ai-generate-text.module'; import { AiWorkspaceStatsModule } from 'src/engine/metadata-modules/ai/ai-workspace-stats/ai-workspace-stats.module'; import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module'; +import { ApplicationJobModule } from 'src/engine/core-modules/application/application-job/application-job.module'; import { ApplicationKeyValueModule } from 'src/engine/core-modules/application/application-key-value/application-key-value.module'; import { CalendarChannelMetadataModule } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.module'; import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module'; @@ -50,6 +51,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work AiGenerateTextModule, AiWorkspaceStatsModule, ApplicationConnectionsModule, + ApplicationJobModule, ApplicationKeyValueModule, MinimalMetadataModule, ViewModule, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/enqueue-job.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/enqueue-job.integration-spec.ts new file mode 100644 index 0000000000..db153b2766 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/enqueue-job.integration-spec.ts @@ -0,0 +1,199 @@ +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import gql from 'graphql-tag'; +import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util'; +import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util'; +import { createOneLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/create-logic-function.util'; +import { deleteLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/delete-logic-function.util'; +import { executeLogicFunction } from 'test/integration/metadata/suites/logic-function/utils/execute-logic-function.util'; +import { updateLogicFunctionSource } from 'test/integration/metadata/suites/logic-function/utils/update-logic-function-source.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { expectEventually } from 'test/integration/utils/expect-eventually.util'; +import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util'; +import { v4 as uuidv4 } from 'uuid'; + +import { WORKSPACE_CUSTOM_APPLICATION_NAME } from 'src/engine/core-modules/application/constants/workspace-custom-application.constant'; +import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; + +const MARKER_DIRECTORY = join(tmpdir(), `enqueue-job-${uuidv4()}`); + +const TARGET_SOURCE_CODE = `import { writeFileSync } from 'node:fs'; + +export const main = async (params: { markerPath: string }): Promise => { + writeFileSync(params.markerPath, 'ran', 'utf-8'); + + return { ok: true }; +};`; + +const ENQUEUE_JOB = gql` + mutation EnqueueJob($input: EnqueueJobInput!) { + enqueueJob(input: $input) { + enqueued + logicFunctionUniversalIdentifier + } + } +`; + +describe('enqueueJob (e2e)', () => { + let customApplicationToken: string; + let standardApplicationToken: string; + let logicFunctionId: string; + let logicFunctionUniversalIdentifier: string; + + beforeAll(async () => { + mkdirSync(MARKER_DIRECTORY, { recursive: true }); + + const { data } = await findManyApplications({ expectToFail: false }); + + const customApplication = data.findManyApplications.find( + (application) => application.name === WORKSPACE_CUSTOM_APPLICATION_NAME, + ); + const standardApplication = data.findManyApplications.find( + (application) => + application.universalIdentifier === + TWENTY_STANDARD_APPLICATION.universalIdentifier, + ); + + expect(customApplication).toBeDefined(); + expect(standardApplication).toBeDefined(); + + const [{ data: customTokenData }, { data: standardTokenData }] = + await Promise.all([ + generateApplicationToken({ + applicationId: customApplication!.id, + expectToFail: false, + }), + generateApplicationToken({ + applicationId: standardApplication!.id, + expectToFail: false, + }), + ]); + + customApplicationToken = + customTokenData.generateApplicationToken.applicationAccessToken.token; + standardApplicationToken = + standardTokenData.generateApplicationToken.applicationAccessToken.token; + + const { data: createData } = await createOneLogicFunction({ + input: { name: `enqueue-job-target-${uuidv4()}` }, + gqlFields: 'id universalIdentifier', + expectToFail: false, + }); + + expect(createData.createOneLogicFunction.universalIdentifier).toBeDefined(); + + logicFunctionId = createData.createOneLogicFunction.id; + logicFunctionUniversalIdentifier = + createData.createOneLogicFunction.universalIdentifier!; + }); + + afterAll(async () => { + await deleteLogicFunction({ + input: { id: logicFunctionId }, + expectToFail: false, + }); + + rmSync(MARKER_DIRECTORY, { recursive: true, force: true }); + }); + + it('rejects requests that do not carry an APPLICATION_ACCESS token', async () => { + const response = await makeMetadataAPIRequest({ + query: ENQUEUE_JOB, + variables: { input: { logicFunctionUniversalIdentifier } }, + }); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toContain('APPLICATION_ACCESS'); + }); + + it('enqueues a logic function owned by the calling application and the worker runs it', async () => { + const markerPath = join(MARKER_DIRECTORY, 'enqueued.txt'); + + await updateLogicFunctionSource({ + input: { + id: logicFunctionId, + update: { sourceHandlerCode: TARGET_SOURCE_CODE }, + }, + expectToFail: false, + }); + + const { data: buildData } = await executeLogicFunction({ + input: { + id: logicFunctionId, + payload: { markerPath: join(MARKER_DIRECTORY, 'build.txt') }, + }, + expectToFail: false, + }); + + expect(buildData.executeOneLogicFunction.error).toBeNull(); + + const response = await makeMetadataAPIRequest( + { + query: ENQUEUE_JOB, + variables: { + input: { logicFunctionUniversalIdentifier, payload: { markerPath } }, + }, + }, + customApplicationToken, + ); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.enqueueJob).toEqual({ + enqueued: true, + logicFunctionUniversalIdentifier, + }); + + await waitForAllJobsToFinish(); + + await expectEventually(() => { + expect(existsSync(markerPath)).toBe(true); + }); + }); + + it('rejects a logic function that belongs to another application', async () => { + const response = await makeMetadataAPIRequest( + { + query: ENQUEUE_JOB, + variables: { + input: { logicFunctionUniversalIdentifier }, + }, + }, + standardApplicationToken, + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toContain('not found'); + }); + + it('rejects an unknown logic function', async () => { + const response = await makeMetadataAPIRequest( + { + query: ENQUEUE_JOB, + variables: { + input: { logicFunctionUniversalIdentifier: uuidv4() }, + }, + }, + customApplicationToken, + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toContain('not found'); + }); + + it('rejects job options outside of their allowed range', async () => { + const response = await makeMetadataAPIRequest( + { + query: ENQUEUE_JOB, + variables: { + input: { logicFunctionUniversalIdentifier, retryLimit: 99 }, + }, + }, + customApplicationToken, + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toContain('retryLimit'); + }); +}); diff --git a/packages/twenty-shared/src/application/enqueueJobType.ts b/packages/twenty-shared/src/application/enqueueJobType.ts new file mode 100644 index 0000000000..4a5041c982 --- /dev/null +++ b/packages/twenty-shared/src/application/enqueueJobType.ts @@ -0,0 +1,14 @@ +export type EnqueueJobOptions = { + retryLimit?: number; + delayMs?: number; +}; + +export type EnqueueJobInput = EnqueueJobOptions & { + logicFunctionUniversalIdentifier: string; + payload?: Record; +}; + +export type EnqueueJobResult = { + enqueued: boolean; + logicFunctionUniversalIdentifier: string; +}; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 613c04218a..404fea6c80 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -88,6 +88,11 @@ export { getViewFilterUniversalIdentifier } from './deterministic-identifier/get export { getViewGroupUniversalIdentifier } from './deterministic-identifier/get-view-group-universal-identifier.util'; export { getViewSortUniversalIdentifier } from './deterministic-identifier/get-view-sort-universal-identifier.util'; export { getViewUniversalIdentifier } from './deterministic-identifier/get-view-universal-identifier.util'; +export type { + EnqueueJobOptions, + EnqueueJobInput, + EnqueueJobResult, +} from './enqueueJobType'; export { SyncableEntity } from './enums/syncable-entities.enum'; export type { RegularFieldManifest, diff --git a/packages/twenty-shared/src/constants/DocumentationPaths.ts b/packages/twenty-shared/src/constants/DocumentationPaths.ts index 081ccab4bc..8b7c520b79 100644 --- a/packages/twenty-shared/src/constants/DocumentationPaths.ts +++ b/packages/twenty-shared/src/constants/DocumentationPaths.ts @@ -57,6 +57,8 @@ export const DOCUMENTATION_PATHS = { DEVELOPERS_EXTEND_APPS_LAYOUT_PAGE_LAYOUTS: '/developers/extend/apps/layout/page-layouts', DEVELOPERS_EXTEND_APPS_LAYOUT_VIEWS: '/developers/extend/apps/layout/views', + DEVELOPERS_EXTEND_APPS_LOGIC_BACKGROUND_JOBS: + '/developers/extend/apps/logic/background-jobs', DEVELOPERS_EXTEND_APPS_LOGIC_CONNECTIONS: '/developers/extend/apps/logic/connections', DEVELOPERS_EXTEND_APPS_LOGIC_KEY_VALUE_STORE: