Ws poc (#11293)
related to https://github.com/twentyhq/core-team-issues/issues/601 ## Done - add a `onDbEvent` `Subscription` graphql endpoint to listen to database_event using what we have done with webhooks: - you can subscribe to any `action` (created, updated, ...) for any `objectNameSingular` or a specific `recordId`. Parameters are nullable and treated as wildcards when null. - returns events with following shape ```typescript @Field(() => String) eventId: string; @Field() emittedAt: string; @Field(() => DatabaseEventAction) action: DatabaseEventAction; @Field(() => String) objectNameSingular: string; @Field(() => GraphQLJSON) record: ObjectRecord; @Field(() => [String], { nullable: true }) updatedFields?: string[]; ``` - front provide a componentEffect `<ListenRecordUpdatesEffect />` that listen for an `objectNameSingular`, a `recordId` and a list of `listenedFields`. It subscribes to record updates and updates its apollo cached value for specified `listenedFields` - subscription is protected with credentials ## Result Here is an application with `workflowRun` https://github.com/user-attachments/assets/c964d857-3b54-495f-bf14-587ba26c5a8c --------- Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
@ -99,7 +99,9 @@
|
|||||||
"graphql-fields": "^2.0.3",
|
"graphql-fields": "^2.0.3",
|
||||||
"graphql-middleware": "^6.1.35",
|
"graphql-middleware": "^6.1.35",
|
||||||
"graphql-rate-limit": "^3.3.0",
|
"graphql-rate-limit": "^3.3.0",
|
||||||
|
"graphql-redis-subscriptions": "^2.7.0",
|
||||||
"graphql-scalars": "^1.23.0",
|
"graphql-scalars": "^1.23.0",
|
||||||
|
"graphql-sse": "^2.5.4",
|
||||||
"graphql-subscriptions": "2.0.0",
|
"graphql-subscriptions": "2.0.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
"graphql-type-json": "^0.3.2",
|
"graphql-type-json": "^0.3.2",
|
||||||
@ -354,7 +356,8 @@
|
|||||||
"type-fest": "4.10.1",
|
"type-fest": "4.10.1",
|
||||||
"typescript": "5.3.3",
|
"typescript": "5.3.3",
|
||||||
"prosemirror-model": "1.23.0",
|
"prosemirror-model": "1.23.0",
|
||||||
"yjs": "13.6.18"
|
"yjs": "13.6.18",
|
||||||
|
"graphql-redis-subscriptions/ioredis": "^5.6.0"
|
||||||
},
|
},
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"nx": {},
|
"nx": {},
|
||||||
|
|||||||
@ -12,7 +12,6 @@ module.exports = {
|
|||||||
'!./src/**/*.test.tsx',
|
'!./src/**/*.test.tsx',
|
||||||
'!./src/**/*.stories.tsx',
|
'!./src/**/*.stories.tsx',
|
||||||
'!./src/**/__mocks__/*.ts',
|
'!./src/**/__mocks__/*.ts',
|
||||||
'!./src/modules/users/graphql/queries/getCurrentUserAndViews.ts',
|
|
||||||
],
|
],
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
generates: {
|
generates: {
|
||||||
|
|||||||
@ -69,6 +69,7 @@ const jestConfig: JestConfigWithTsJest = {
|
|||||||
'config/*',
|
'config/*',
|
||||||
'graphql/queries/*',
|
'graphql/queries/*',
|
||||||
'graphql/mutations/*',
|
'graphql/mutations/*',
|
||||||
|
'graphql/subscriptions/*',
|
||||||
'graphql/fragments/*',
|
'graphql/fragments/*',
|
||||||
'types/*',
|
'types/*',
|
||||||
'constants/*',
|
'constants/*',
|
||||||
|
|||||||
@ -9,7 +9,7 @@ const globalCoverage = {
|
|||||||
|
|
||||||
const modulesCoverage = {
|
const modulesCoverage = {
|
||||||
branches: 25,
|
branches: 25,
|
||||||
statements: 44,
|
statements: 43,
|
||||||
lines: 44,
|
lines: 44,
|
||||||
functions: 38,
|
functions: 38,
|
||||||
include: ['src/modules/**/*'],
|
include: ['src/modules/**/*'],
|
||||||
|
|||||||
@ -453,6 +453,15 @@ export type CustomDomainValidRecords = {
|
|||||||
records: Array<CustomDomainRecord>;
|
records: Array<CustomDomainRecord>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Database Event Action */
|
||||||
|
export enum DatabaseEventAction {
|
||||||
|
CREATED = 'CREATED',
|
||||||
|
DELETED = 'DELETED',
|
||||||
|
DESTROYED = 'DESTROYED',
|
||||||
|
RESTORED = 'RESTORED',
|
||||||
|
UPDATED = 'UPDATED'
|
||||||
|
}
|
||||||
|
|
||||||
export type DateFilter = {
|
export type DateFilter = {
|
||||||
eq?: InputMaybe<Scalars['Date']>;
|
eq?: InputMaybe<Scalars['Date']>;
|
||||||
gt?: InputMaybe<Scalars['Date']>;
|
gt?: InputMaybe<Scalars['Date']>;
|
||||||
@ -1366,6 +1375,21 @@ export type ObjectStandardOverrides = {
|
|||||||
translations?: Maybe<Scalars['JSON']>;
|
translations?: Maybe<Scalars['JSON']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OnDbEventDto = {
|
||||||
|
__typename?: 'OnDbEventDTO';
|
||||||
|
action: DatabaseEventAction;
|
||||||
|
eventDate: Scalars['DateTime'];
|
||||||
|
objectNameSingular: Scalars['String'];
|
||||||
|
record: Scalars['JSON'];
|
||||||
|
updatedFields?: Maybe<Array<Scalars['String']>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnDbEventInput = {
|
||||||
|
action?: InputMaybe<DatabaseEventAction>;
|
||||||
|
objectNameSingular?: InputMaybe<Scalars['String']>;
|
||||||
|
recordId?: InputMaybe<Scalars['String']>;
|
||||||
|
};
|
||||||
|
|
||||||
/** Onboarding status */
|
/** Onboarding status */
|
||||||
export enum OnboardingStatus {
|
export enum OnboardingStatus {
|
||||||
COMPLETED = 'COMPLETED',
|
COMPLETED = 'COMPLETED',
|
||||||
@ -1885,6 +1909,16 @@ export type SubmitFormStepInput = {
|
|||||||
workflowRunId: Scalars['String'];
|
workflowRunId: Scalars['String'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Subscription = {
|
||||||
|
__typename?: 'Subscription';
|
||||||
|
onDbEvent: OnDbEventDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type SubscriptionOnDbEventArgs = {
|
||||||
|
input: OnDbEventInput;
|
||||||
|
};
|
||||||
|
|
||||||
export enum SubscriptionInterval {
|
export enum SubscriptionInterval {
|
||||||
Day = 'Day',
|
Day = 'Day',
|
||||||
Month = 'Month',
|
Month = 'Month',
|
||||||
@ -2796,6 +2830,13 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
|
|||||||
|
|
||||||
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
|
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
|
||||||
|
|
||||||
|
export type OnDbEventSubscriptionVariables = Exact<{
|
||||||
|
input: OnDbEventInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type OnDbEventSubscription = { __typename?: 'Subscription', onDbEvent: { __typename?: 'OnDbEventDTO', eventDate: string, action: DatabaseEventAction, objectNameSingular: string, updatedFields?: Array<string> | null, record: any } };
|
||||||
|
|
||||||
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
|
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingPermissionType> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItem', id: any, hasReachedCurrentPeriodCap: boolean, billingProduct?: { __typename?: 'BillingProduct', name: string, description: string, metadata: { __typename?: 'BillingProductMetadata', planKey: BillingPlanKey, priceUsageBased: BillingUsageType, productKey: BillingProductKey } } | null }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean } | null } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
|
||||||
|
|
||||||
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
|
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
|
||||||
@ -5420,6 +5461,40 @@ export function useGetSsoIdentityProvidersLazyQuery(baseOptions?: Apollo.LazyQue
|
|||||||
export type GetSsoIdentityProvidersQueryHookResult = ReturnType<typeof useGetSsoIdentityProvidersQuery>;
|
export type GetSsoIdentityProvidersQueryHookResult = ReturnType<typeof useGetSsoIdentityProvidersQuery>;
|
||||||
export type GetSsoIdentityProvidersLazyQueryHookResult = ReturnType<typeof useGetSsoIdentityProvidersLazyQuery>;
|
export type GetSsoIdentityProvidersLazyQueryHookResult = ReturnType<typeof useGetSsoIdentityProvidersLazyQuery>;
|
||||||
export type GetSsoIdentityProvidersQueryResult = Apollo.QueryResult<GetSsoIdentityProvidersQuery, GetSsoIdentityProvidersQueryVariables>;
|
export type GetSsoIdentityProvidersQueryResult = Apollo.QueryResult<GetSsoIdentityProvidersQuery, GetSsoIdentityProvidersQueryVariables>;
|
||||||
|
export const OnDbEventDocument = gql`
|
||||||
|
subscription OnDbEvent($input: OnDbEventInput!) {
|
||||||
|
onDbEvent(input: $input) {
|
||||||
|
eventDate
|
||||||
|
action
|
||||||
|
objectNameSingular
|
||||||
|
updatedFields
|
||||||
|
record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useOnDbEventSubscription__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useOnDbEventSubscription` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useOnDbEventSubscription` returns an object from Apollo Client that contains loading, error, and data properties
|
||||||
|
* you can use to render your UI.
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading, error } = useOnDbEventSubscription({
|
||||||
|
* variables: {
|
||||||
|
* input: // value for 'input'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useOnDbEventSubscription(baseOptions: Apollo.SubscriptionHookOptions<OnDbEventSubscription, OnDbEventSubscriptionVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useSubscription<OnDbEventSubscription, OnDbEventSubscriptionVariables>(OnDbEventDocument, options);
|
||||||
|
}
|
||||||
|
export type OnDbEventSubscriptionHookResult = ReturnType<typeof useOnDbEventSubscription>;
|
||||||
|
export type OnDbEventSubscriptionResult = Apollo.SubscriptionResult<OnDbEventSubscription>;
|
||||||
export const DeleteUserAccountDocument = gql`
|
export const DeleteUserAccountDocument = gql`
|
||||||
mutation DeleteUserAccount {
|
mutation DeleteUserAccount {
|
||||||
deleteUser {
|
deleteUser {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { cookieStorage } from '~/utils/cookie-storage';
|
|||||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||||
import { ApolloManager } from '../types/apolloManager.interface';
|
import { ApolloManager } from '../types/apolloManager.interface';
|
||||||
import { loggerLink } from '../utils/loggerLink';
|
import { loggerLink } from '../utils/loggerLink';
|
||||||
|
import { getTokenPair } from '../utils/getTokenPair';
|
||||||
|
|
||||||
const logger = loggerLink(() => 'Twenty');
|
const logger = loggerLink(() => 'Twenty');
|
||||||
|
|
||||||
@ -55,14 +56,6 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
|||||||
|
|
||||||
this.currentWorkspaceMember = currentWorkspaceMember;
|
this.currentWorkspaceMember = currentWorkspaceMember;
|
||||||
|
|
||||||
const getTokenPair = () => {
|
|
||||||
const stringTokenPair = cookieStorage.getItem('tokenPair');
|
|
||||||
const tokenPair = isDefined(stringTokenPair)
|
|
||||||
? (JSON.parse(stringTokenPair) as AuthTokenPair)
|
|
||||||
: undefined;
|
|
||||||
return tokenPair;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildApolloLink = (): ApolloLink => {
|
const buildApolloLink = (): ApolloLink => {
|
||||||
const httpLink = createUploadLink({
|
const httpLink = createUploadLink({
|
||||||
uri,
|
uri,
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { cookieStorage } from '~/utils/cookie-storage';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { AuthTokenPair } from '~/generated/graphql';
|
||||||
|
|
||||||
|
export const getTokenPair = () => {
|
||||||
|
const stringTokenPair = cookieStorage.getItem('tokenPair');
|
||||||
|
return isDefined(stringTokenPair)
|
||||||
|
? (JSON.parse(stringTokenPair) as AuthTokenPair)
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
@ -15,6 +15,7 @@ import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/com
|
|||||||
import { WorkflowVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVisualizer';
|
import { WorkflowVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVisualizer';
|
||||||
import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect';
|
import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenUpdatesEffect';
|
||||||
|
|
||||||
const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
|
const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
|
||||||
background: ${({ theme, isInRightDrawer }) =>
|
background: ${({ theme, isInRightDrawer }) =>
|
||||||
@ -99,6 +100,11 @@ export const CardComponents: Record<CardType, CardComponentType> = {
|
|||||||
[CardType.WorkflowRunCard]: ({ targetableObject }) => (
|
[CardType.WorkflowRunCard]: ({ targetableObject }) => (
|
||||||
<>
|
<>
|
||||||
<WorkflowRunVisualizerEffect workflowRunId={targetableObject.id} />
|
<WorkflowRunVisualizerEffect workflowRunId={targetableObject.id} />
|
||||||
|
<ListenRecordUpdatesEffect
|
||||||
|
objectNameSingular={targetableObject.targetObjectNameSingular}
|
||||||
|
recordId={targetableObject.id}
|
||||||
|
listenedFields={['status', 'output']}
|
||||||
|
/>
|
||||||
|
|
||||||
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
|
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { useApolloClient } from '@apollo/client';
|
||||||
|
import { useOnDbEvent } from '@/subscription/hooks/useOnDbEvent';
|
||||||
|
import { DatabaseEventAction } from '~/generated/graphql';
|
||||||
|
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
type ListenRecordUpdatesEffectProps = {
|
||||||
|
objectNameSingular: string;
|
||||||
|
recordId: string;
|
||||||
|
listenedFields: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ListenRecordUpdatesEffect = ({
|
||||||
|
objectNameSingular,
|
||||||
|
recordId,
|
||||||
|
listenedFields,
|
||||||
|
}: ListenRecordUpdatesEffectProps) => {
|
||||||
|
const apolloClient = useApolloClient();
|
||||||
|
|
||||||
|
useOnDbEvent({
|
||||||
|
input: { recordId, action: DatabaseEventAction.UPDATED },
|
||||||
|
onData: (data) => {
|
||||||
|
const updatedRecord = data.onDbEvent.record;
|
||||||
|
|
||||||
|
const fieldsUpdater = listenedFields.reduce((acc, listenedField) => {
|
||||||
|
if (!isDefined(updatedRecord[listenedField])) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[listenedField]: () => updatedRecord[listenedField],
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
apolloClient.cache.modify({
|
||||||
|
id: apolloClient.cache.identify({
|
||||||
|
__typename: capitalize(objectNameSingular),
|
||||||
|
id: recordId,
|
||||||
|
}),
|
||||||
|
fields: fieldsUpdater,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const ON_DB_EVENT = gql`
|
||||||
|
subscription OnDbEvent($input: OnDbEventInput!) {
|
||||||
|
onDbEvent(input: $input) {
|
||||||
|
eventDate
|
||||||
|
action
|
||||||
|
objectNameSingular
|
||||||
|
updatedFields
|
||||||
|
record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { createClient } from 'graphql-sse';
|
||||||
|
import { ON_DB_EVENT } from '@/subscription/graphql/subscriptions/onDbEvent';
|
||||||
|
import { Subscription, SubscriptionOnDbEventArgs } from '~/generated/graphql';
|
||||||
|
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||||
|
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||||
|
|
||||||
|
type OnDbEventArgs = SubscriptionOnDbEventArgs & {
|
||||||
|
skip?: boolean;
|
||||||
|
onData?: (data: Subscription) => void;
|
||||||
|
onError?: (err: any) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOnDbEvent = ({
|
||||||
|
onData,
|
||||||
|
onError,
|
||||||
|
onComplete,
|
||||||
|
input,
|
||||||
|
skip = false,
|
||||||
|
}: OnDbEventArgs) => {
|
||||||
|
const tokenPair = getTokenPair();
|
||||||
|
|
||||||
|
const sseClient = useMemo(() => {
|
||||||
|
return createClient({
|
||||||
|
url: `${REACT_APP_SERVER_BASE_URL}/graphql`,
|
||||||
|
headers: {
|
||||||
|
Authorization: tokenPair?.accessToken.token
|
||||||
|
? `Bearer ${tokenPair?.accessToken.token}`
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [tokenPair?.accessToken.token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skip === true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = (value: { data: Subscription }) => onData?.(value.data);
|
||||||
|
const error = (err: unknown) => onError?.(err);
|
||||||
|
const complete = () => onComplete?.();
|
||||||
|
const unsubscribe = sseClient.subscribe(
|
||||||
|
{
|
||||||
|
query: ON_DB_EVENT.loc?.source.body || '',
|
||||||
|
variables: { input },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
next,
|
||||||
|
error,
|
||||||
|
complete,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [input, onComplete, onData, onError, skip, sseClient]);
|
||||||
|
};
|
||||||
@ -16,6 +16,7 @@ import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/wo
|
|||||||
import { CreateAuditLogFromInternalEvent } from 'src/modules/timeline/jobs/create-audit-log-from-internal-event';
|
import { CreateAuditLogFromInternalEvent } from 'src/modules/timeline/jobs/create-audit-log-from-internal-event';
|
||||||
import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job';
|
import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job';
|
||||||
import { CallWebhookJobsJob } from 'src/modules/webhook/jobs/call-webhook-jobs.job';
|
import { CallWebhookJobsJob } from 'src/modules/webhook/jobs/call-webhook-jobs.job';
|
||||||
|
import { SubscriptionsJob } from 'src/engine/subscriptions/subscriptions.job';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EntityEventsToDbListener {
|
export class EntityEventsToDbListener {
|
||||||
@ -24,6 +25,8 @@ export class EntityEventsToDbListener {
|
|||||||
private readonly entityEventsToDbQueueService: MessageQueueService,
|
private readonly entityEventsToDbQueueService: MessageQueueService,
|
||||||
@InjectMessageQueue(MessageQueue.webhookQueue)
|
@InjectMessageQueue(MessageQueue.webhookQueue)
|
||||||
private readonly webhookQueueService: MessageQueueService,
|
private readonly webhookQueueService: MessageQueueService,
|
||||||
|
@InjectMessageQueue(MessageQueue.subscriptionsQueue)
|
||||||
|
private readonly subscriptionsQueueService: MessageQueueService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnDatabaseBatchEvent('*', DatabaseEventAction.CREATED)
|
@OnDatabaseBatchEvent('*', DatabaseEventAction.CREATED)
|
||||||
@ -64,6 +67,11 @@ export class EntityEventsToDbListener {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
this.subscriptionsQueueService.add<WorkspaceEventBatch<T>>(
|
||||||
|
SubscriptionsJob.name,
|
||||||
|
batchEvent,
|
||||||
|
{ retryLimit: 3 },
|
||||||
|
),
|
||||||
this.webhookQueueService.add<WorkspaceEventBatch<T>>(
|
this.webhookQueueService.add<WorkspaceEventBatch<T>>(
|
||||||
CallWebhookJobsJob.name,
|
CallWebhookJobsJob.name,
|
||||||
batchEvent,
|
batchEvent,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { GraphqlQueryRunnerModule } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module';
|
import { GraphqlQueryRunnerModule } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module';
|
||||||
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
|
|
||||||
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
|
import { WorkspaceResolverBuilderService } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service';
|
||||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||||
|
|
||||||
@ -10,11 +9,7 @@ import { WorkspaceResolverFactory } from './workspace-resolver.factory';
|
|||||||
import { workspaceResolverBuilderFactories } from './factories/factories';
|
import { workspaceResolverBuilderFactories } from './factories/factories';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [GraphqlQueryRunnerModule, FeatureFlagModule],
|
||||||
WorkspaceQueryRunnerModule,
|
|
||||||
GraphqlQueryRunnerModule,
|
|
||||||
FeatureFlagModule,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
...workspaceResolverBuilderFactories,
|
...workspaceResolverBuilderFactories,
|
||||||
WorkspaceResolverFactory,
|
WorkspaceResolverFactory,
|
||||||
|
|||||||
@ -48,6 +48,8 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
|
|||||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||||
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||||
|
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
|
||||||
|
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||||
|
|
||||||
import { AnalyticsModule } from './analytics/analytics.module';
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
import { ClientConfigModule } from './client-config/client-config.module';
|
import { ClientConfigModule } from './client-config/client-config.module';
|
||||||
@ -81,6 +83,8 @@ import { FileModule } from './file/file.module';
|
|||||||
RoleModule,
|
RoleModule,
|
||||||
TwentyConfigModule,
|
TwentyConfigModule,
|
||||||
RedisClientModule,
|
RedisClientModule,
|
||||||
|
WorkspaceQueryRunnerModule,
|
||||||
|
SubscriptionsModule,
|
||||||
FileStorageModule.forRootAsync({
|
FileStorageModule.forRootAsync({
|
||||||
useFactory: fileStorageModuleFactory,
|
useFactory: fileStorageModuleFactory,
|
||||||
inject: [TwentyConfigService],
|
inject: [TwentyConfigService],
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module
|
|||||||
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
|
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
|
||||||
import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module';
|
import { WebhookJobModule } from 'src/modules/webhook/jobs/webhook-job.module';
|
||||||
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
||||||
|
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -58,6 +59,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
|
|||||||
WorkflowModule,
|
WorkflowModule,
|
||||||
FavoriteModule,
|
FavoriteModule,
|
||||||
WorkspaceCleanerModule,
|
WorkspaceCleanerModule,
|
||||||
|
SubscriptionsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
CleanSuspendedWorkspacesJob,
|
CleanSuspendedWorkspacesJob,
|
||||||
|
|||||||
@ -19,4 +19,5 @@ export enum MessageQueue {
|
|||||||
workflowQueue = 'workflow-queue',
|
workflowQueue = 'workflow-queue',
|
||||||
serverlessFunctionQueue = 'serverless-function-queue',
|
serverlessFunctionQueue = 'serverless-function-queue',
|
||||||
deleteCascadeQueue = 'delete-cascade-queue',
|
deleteCascadeQueue = 'delete-cascade-queue',
|
||||||
|
subscriptionsQueue = 'subscriptions-queue',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import GraphQLJSON from 'graphql-type-json';
|
||||||
|
|
||||||
|
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||||
|
|
||||||
|
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||||
|
|
||||||
|
registerEnumType(DatabaseEventAction, {
|
||||||
|
name: 'DatabaseEventAction',
|
||||||
|
description: 'Database Event Action',
|
||||||
|
});
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class OnDbEventDTO {
|
||||||
|
@Field(() => DatabaseEventAction)
|
||||||
|
action: DatabaseEventAction;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
objectNameSingular: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
eventDate: Date;
|
||||||
|
|
||||||
|
@Field(() => GraphQLJSON)
|
||||||
|
record: ObjectRecord;
|
||||||
|
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
updatedFields?: string[];
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { Field, InputType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class OnDbEventInput {
|
||||||
|
@Field(() => DatabaseEventAction, { nullable: true })
|
||||||
|
action?: DatabaseEventAction;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
objectNameSingular?: string;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
recordId?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||||
|
|
||||||
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
|
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||||
|
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||||
|
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
|
||||||
|
import { ObjectRecordEvent } from 'src/engine/core-modules/event-emitter/types/object-record-event.event';
|
||||||
|
import { removeSecretFromWebhookRecord } from 'src/utils/remove-secret-from-webhook-record';
|
||||||
|
|
||||||
|
@Processor(MessageQueue.subscriptionsQueue)
|
||||||
|
export class SubscriptionsJob {
|
||||||
|
constructor(@Inject('PUB_SUB') private readonly pubSub: RedisPubSub) {}
|
||||||
|
|
||||||
|
@Process(SubscriptionsJob.name)
|
||||||
|
async handle(
|
||||||
|
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const eventData of workspaceEventBatch.events) {
|
||||||
|
const [nameSingular, operation] = workspaceEventBatch.name.split('.');
|
||||||
|
const record =
|
||||||
|
'after' in eventData.properties && isDefined(eventData.properties.after)
|
||||||
|
? eventData.properties.after
|
||||||
|
: 'before' in eventData.properties &&
|
||||||
|
isDefined(eventData.properties.before)
|
||||||
|
? eventData.properties.before
|
||||||
|
: {};
|
||||||
|
const updatedFields =
|
||||||
|
'updatedFields' in eventData.properties
|
||||||
|
? eventData.properties.updatedFields
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const isWebhookEvent = nameSingular === 'webhook';
|
||||||
|
const sanitizedRecord = removeSecretFromWebhookRecord(
|
||||||
|
record,
|
||||||
|
isWebhookEvent,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.pubSub.publish('onDbEvent', {
|
||||||
|
onDbEvent: {
|
||||||
|
action: operation,
|
||||||
|
objectNameSingular: nameSingular,
|
||||||
|
eventDate: new Date(),
|
||||||
|
record: sanitizedRecord,
|
||||||
|
...(updatedFields && { updatedFields }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { Inject, Module, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||||
|
|
||||||
|
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||||
|
import { SubscriptionsResolver } from 'src/engine/subscriptions/subscriptions.resolver';
|
||||||
|
import { SubscriptionsJob } from 'src/engine/subscriptions/subscriptions.job';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
exports: ['PUB_SUB'],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: 'PUB_SUB',
|
||||||
|
inject: [RedisClientService],
|
||||||
|
|
||||||
|
useFactory: (redisClientService: RedisClientService) =>
|
||||||
|
new RedisPubSub({
|
||||||
|
publisher: redisClientService.getClient().duplicate(),
|
||||||
|
subscriber: redisClientService.getClient().duplicate(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
SubscriptionsResolver,
|
||||||
|
SubscriptionsJob,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class SubscriptionsModule implements OnModuleDestroy {
|
||||||
|
constructor(@Inject('PUB_SUB') private readonly pubSub: RedisPubSub) {}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.pubSub) {
|
||||||
|
await this.pubSub.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { Args, Resolver, Subscription } from '@nestjs/graphql';
|
||||||
|
import { Inject, UseGuards } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
import { OnDbEventDTO } from 'src/engine/subscriptions/dtos/on-db-event.dto';
|
||||||
|
import { OnDbEventInput } from 'src/engine/subscriptions/dtos/on-db-event.input';
|
||||||
|
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
|
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
|
||||||
|
export class SubscriptionsResolver {
|
||||||
|
constructor(@Inject('PUB_SUB') private readonly pubSub: RedisPubSub) {}
|
||||||
|
|
||||||
|
@Subscription(() => OnDbEventDTO, {
|
||||||
|
filter: (
|
||||||
|
payload: { onDbEvent: OnDbEventDTO },
|
||||||
|
variables: { input: OnDbEventInput },
|
||||||
|
) => {
|
||||||
|
const isActionMatching =
|
||||||
|
!isDefined(variables.input.action) ||
|
||||||
|
payload.onDbEvent.action === variables.input.action;
|
||||||
|
|
||||||
|
const isObjectNameSingularMatching =
|
||||||
|
!isDefined(variables.input.objectNameSingular) ||
|
||||||
|
payload.onDbEvent.objectNameSingular ===
|
||||||
|
variables.input.objectNameSingular;
|
||||||
|
|
||||||
|
const isRecordIdMatching =
|
||||||
|
!isDefined(variables.input.recordId) ||
|
||||||
|
payload.onDbEvent.record.id === variables.input.recordId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
isActionMatching && isObjectNameSingularMatching && isRecordIdMatching
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
onDbEvent(@Args('input') _: OnDbEventInput) {
|
||||||
|
return this.pubSub.asyncIterator('onDbEvent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,4 +12,5 @@ export enum WorkflowRunExceptionCode {
|
|||||||
INVALID_INPUT = 'INVALID_INPUT',
|
INVALID_INPUT = 'INVALID_INPUT',
|
||||||
WORKFLOW_RUN_LIMIT_REACHED = 'WORKFLOW_RUN_LIMIT_REACHED',
|
WORKFLOW_RUN_LIMIT_REACHED = 'WORKFLOW_RUN_LIMIT_REACHED',
|
||||||
WORKFLOW_RUN_INVALID = 'WORKFLOW_RUN_INVALID',
|
WORKFLOW_RUN_INVALID = 'WORKFLOW_RUN_INVALID',
|
||||||
|
FAILURE = 'FAILURE',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||||
|
|
||||||
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
|
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
|
||||||
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
|
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
|
||||||
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
|
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
|
||||||
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
||||||
|
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkflowCommonModule, RecordPositionModule],
|
imports: [
|
||||||
|
WorkflowCommonModule,
|
||||||
|
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
|
||||||
|
RecordPositionModule,
|
||||||
|
],
|
||||||
providers: [WorkflowRunWorkspaceService, ScopedWorkspaceContextFactory],
|
providers: [WorkflowRunWorkspaceService, ScopedWorkspaceContextFactory],
|
||||||
exports: [WorkflowRunWorkspaceService],
|
exports: [WorkflowRunWorkspaceService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||||
import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
|
import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
|
||||||
@ -17,14 +20,20 @@ import {
|
|||||||
WorkflowRunException,
|
WorkflowRunException,
|
||||||
WorkflowRunExceptionCode,
|
WorkflowRunExceptionCode,
|
||||||
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
|
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
|
||||||
|
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||||
|
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||||
|
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkflowRunWorkspaceService {
|
export class WorkflowRunWorkspaceService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly twentyORMManager: TwentyORMManager,
|
private readonly twentyORMManager: TwentyORMManager,
|
||||||
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
|
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
|
||||||
private readonly recordPositionService: RecordPositionService,
|
|
||||||
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
|
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
|
||||||
|
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||||
|
@InjectRepository(ObjectMetadataEntity, 'metadata')
|
||||||
|
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||||
|
private readonly recordPositionService: RecordPositionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createWorkflowRun({
|
async createWorkflowRun({
|
||||||
@ -131,11 +140,19 @@ export class WorkflowRunWorkspaceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return workflowRunRepository.update(workflowRunToUpdate.id, {
|
const partialUpdate = {
|
||||||
status: WorkflowRunStatus.RUNNING,
|
status: WorkflowRunStatus.RUNNING,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
context,
|
context,
|
||||||
output,
|
output,
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowRunRepository.update(workflowRunToUpdate.id, partialUpdate);
|
||||||
|
|
||||||
|
await this.emitWorkflowRunUpdatedEvent({
|
||||||
|
workflowRunBefore: workflowRunToUpdate,
|
||||||
|
diff: partialUpdate,
|
||||||
|
updatedFields: ['status', 'startedAt', 'context', 'output'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,13 +181,21 @@ export class WorkflowRunWorkspaceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return workflowRunRepository.update(workflowRunToUpdate.id, {
|
const partialUpdate = {
|
||||||
status,
|
status,
|
||||||
endedAt: new Date().toISOString(),
|
endedAt: new Date().toISOString(),
|
||||||
output: {
|
output: {
|
||||||
...(workflowRunToUpdate.output ?? {}),
|
...(workflowRunToUpdate.output ?? {}),
|
||||||
error,
|
error,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowRunRepository.update(workflowRunToUpdate.id, partialUpdate);
|
||||||
|
|
||||||
|
await this.emitWorkflowRunUpdatedEvent({
|
||||||
|
workflowRunBefore: workflowRunToUpdate,
|
||||||
|
diff: partialUpdate,
|
||||||
|
updatedFields: ['status', 'endedAt', 'output'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,7 +224,7 @@ export class WorkflowRunWorkspaceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return workflowRunRepository.update(workflowRunId, {
|
const partialUpdate = {
|
||||||
output: {
|
output: {
|
||||||
flow: workflowRunToUpdate.output?.flow ?? {
|
flow: workflowRunToUpdate.output?.flow ?? {
|
||||||
trigger: undefined,
|
trigger: undefined,
|
||||||
@ -211,6 +236,14 @@ export class WorkflowRunWorkspaceService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowRunRepository.update(workflowRunId, partialUpdate);
|
||||||
|
|
||||||
|
await this.emitWorkflowRunUpdatedEvent({
|
||||||
|
workflowRunBefore: workflowRunToUpdate,
|
||||||
|
diff: partialUpdate,
|
||||||
|
updatedFields: ['context', 'output'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,7 +284,7 @@ export class WorkflowRunWorkspaceService {
|
|||||||
(existingStep) => (step.id === existingStep.id ? step : existingStep),
|
(existingStep) => (step.id === existingStep.id ? step : existingStep),
|
||||||
);
|
);
|
||||||
|
|
||||||
return workflowRunRepository.update(workflowRunToUpdate.id, {
|
const partialUpdate = {
|
||||||
output: {
|
output: {
|
||||||
...(workflowRunToUpdate.output ?? {}),
|
...(workflowRunToUpdate.output ?? {}),
|
||||||
flow: {
|
flow: {
|
||||||
@ -259,6 +292,14 @@ export class WorkflowRunWorkspaceService {
|
|||||||
steps: updatedSteps,
|
steps: updatedSteps,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowRunRepository.update(workflowRunToUpdate.id, partialUpdate);
|
||||||
|
|
||||||
|
await this.emitWorkflowRunUpdatedEvent({
|
||||||
|
workflowRunBefore: workflowRunToUpdate,
|
||||||
|
diff: partialUpdate,
|
||||||
|
updatedFields: ['output'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,4 +324,68 @@ export class WorkflowRunWorkspaceService {
|
|||||||
|
|
||||||
return workflowRun;
|
return workflowRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async emitWorkflowRunUpdatedEvent({
|
||||||
|
workflowRunBefore,
|
||||||
|
updatedFields,
|
||||||
|
diff,
|
||||||
|
}: {
|
||||||
|
workflowRunBefore: WorkflowRunWorkspaceEntity;
|
||||||
|
updatedFields: string[];
|
||||||
|
diff: object;
|
||||||
|
}) {
|
||||||
|
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectMetadata = await this.objectMetadataRepository.findOne({
|
||||||
|
where: {
|
||||||
|
nameSingular: 'workflowRun',
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!objectMetadata) {
|
||||||
|
throw new WorkflowRunException(
|
||||||
|
'Object metadata not found',
|
||||||
|
WorkflowRunExceptionCode.FAILURE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowRunRepository =
|
||||||
|
await this.twentyORMManager.getRepository<WorkflowRunWorkspaceEntity>(
|
||||||
|
'workflowRun',
|
||||||
|
);
|
||||||
|
|
||||||
|
const workflowRunAfter = await workflowRunRepository.findOneBy({
|
||||||
|
id: workflowRunBefore.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workflowRunAfter) {
|
||||||
|
throw new WorkflowRunException(
|
||||||
|
'WorkflowRun not found',
|
||||||
|
WorkflowRunExceptionCode.FAILURE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||||
|
objectMetadataNameSingular: 'workflowRun',
|
||||||
|
action: DatabaseEventAction.UPDATED,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
recordId: workflowRunBefore.id,
|
||||||
|
objectMetadata,
|
||||||
|
properties: {
|
||||||
|
after: workflowRunAfter,
|
||||||
|
before: workflowRunBefore,
|
||||||
|
updatedFields,
|
||||||
|
diff,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
yarn.lock
33
yarn.lock
@ -36139,6 +36139,20 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"graphql-redis-subscriptions@npm:^2.7.0":
|
||||||
|
version: 2.7.0
|
||||||
|
resolution: "graphql-redis-subscriptions@npm:2.7.0"
|
||||||
|
dependencies:
|
||||||
|
ioredis: "npm:^5.3.2"
|
||||||
|
peerDependencies:
|
||||||
|
graphql-subscriptions: ^1.0.0 || ^2.0.0 || ^3.0.0
|
||||||
|
dependenciesMeta:
|
||||||
|
ioredis:
|
||||||
|
optional: true
|
||||||
|
checksum: 10c0/f98e9a16aa60d5470f6916f5a85b0b91898e3ec341a70ae3ddac878aa5b415dae9081ba872afdab5873cef3933fde1c3f1ee690ffa6d5b6d164a9165aed5cad1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"graphql-request@npm:^6.0.0":
|
"graphql-request@npm:^6.0.0":
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
resolution: "graphql-request@npm:6.1.0"
|
resolution: "graphql-request@npm:6.1.0"
|
||||||
@ -36177,6 +36191,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"graphql-sse@npm:^2.5.4":
|
||||||
|
version: 2.5.4
|
||||||
|
resolution: "graphql-sse@npm:2.5.4"
|
||||||
|
peerDependencies:
|
||||||
|
graphql: ">=0.11 <=16"
|
||||||
|
checksum: 10c0/b2635a4098b86492ecb04e7aab7efb715847ad11b785591cf90cf1f342ba78a03db0c6cf903975a7ad9e2c278dee55cd9e2e9d7edb560f334528bb8ee926ed95
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"graphql-subscriptions@npm:2.0.0":
|
"graphql-subscriptions@npm:2.0.0":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "graphql-subscriptions@npm:2.0.0"
|
resolution: "graphql-subscriptions@npm:2.0.0"
|
||||||
@ -38133,9 +38156,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"ioredis@npm:^5.4.1":
|
"ioredis@npm:^5.4.1, ioredis@npm:^5.6.0":
|
||||||
version: 5.4.2
|
version: 5.6.0
|
||||||
resolution: "ioredis@npm:5.4.2"
|
resolution: "ioredis@npm:5.6.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@ioredis/commands": "npm:^1.1.1"
|
"@ioredis/commands": "npm:^1.1.1"
|
||||||
cluster-key-slot: "npm:^1.1.0"
|
cluster-key-slot: "npm:^1.1.0"
|
||||||
@ -38146,7 +38169,7 @@ __metadata:
|
|||||||
redis-errors: "npm:^1.2.0"
|
redis-errors: "npm:^1.2.0"
|
||||||
redis-parser: "npm:^3.0.0"
|
redis-parser: "npm:^3.0.0"
|
||||||
standard-as-callback: "npm:^2.1.0"
|
standard-as-callback: "npm:^2.1.0"
|
||||||
checksum: 10c0/e59d2cceb43ed74b487d7b50fa91b93246e734e5d4835c7e62f64e44da072f12ab43b044248012e6f8b76c61a7c091a2388caad50e8ad69a8ce5515a730b23b8
|
checksum: 10c0/a885e5146640fc448706871290ef424ffa39af561f7ee3cf1590085209a509f85e99082bdaaf3cd32fa66758aea3fc2055d1109648ddca96fac4944bf2092c30
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -55380,7 +55403,9 @@ __metadata:
|
|||||||
graphql-fields: "npm:^2.0.3"
|
graphql-fields: "npm:^2.0.3"
|
||||||
graphql-middleware: "npm:^6.1.35"
|
graphql-middleware: "npm:^6.1.35"
|
||||||
graphql-rate-limit: "npm:^3.3.0"
|
graphql-rate-limit: "npm:^3.3.0"
|
||||||
|
graphql-redis-subscriptions: "npm:^2.7.0"
|
||||||
graphql-scalars: "npm:^1.23.0"
|
graphql-scalars: "npm:^1.23.0"
|
||||||
|
graphql-sse: "npm:^2.5.4"
|
||||||
graphql-subscriptions: "npm:2.0.0"
|
graphql-subscriptions: "npm:2.0.0"
|
||||||
graphql-tag: "npm:^2.12.6"
|
graphql-tag: "npm:^2.12.6"
|
||||||
graphql-type-json: "npm:^0.3.2"
|
graphql-type-json: "npm:^0.3.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user