Fix AI campaign editing: tool auth context in workers and live editor resync (#23811)
Fixes two issues reported when creating an email campaign through the AI chat panel. ## 1. `save_campaign` failed with "Workspace auth context not set" The AI chat streams inside a queue worker job, where no HTTP middleware populates the async-local workspace auth context. The new `MessageCampaignDraftService.saveDraft()` relies on `executeInWorkspaceContext()`'s fallback to `getWorkspaceAuthContext()`, which throws outside HTTP requests. Database CRUD tools worked because `dispatchDatabaseCrud` builds an auth context explicitly; static tools had no equivalent. **Fix:** `ToolExecutorService.dispatch` (the single choke point for all tool executions: preloaded chat tools, `execute_tool`, MCP, workflow agents) now resolves the acting identity once, reusing a provided auth context or building a user context from `userId`/`userWorkspaceId`, and runs the dispatch inside `withWorkspaceAuthContext()`. This mirrors what `WorkspaceAuthContextMiddleware` does for HTTP requests, so tool code can rely on the async-local context on every transport. Side benefit: metadata tools executed from chat previously emitted metadata events with no user attribution (`MetadataEventEmitter` swallows the missing context); they are now attributed correctly. ## 2. AI changes to the open campaign required a page refresh The SSE pipeline delivers worker-originated record updates to the Apollo cache correctly. The campaign editor ignored them: `usePersistedCampaignDraft` seeds local draft state from the record once, and the subject/body/list inputs are uncontrolled (TipTap reads `defaultValue` on mount only). **Fix:** the draft hook now adopts upstream record values while the draft is pristine and exposes a `draftResyncKey` that remounts the `defaultValue`-seeded inputs. Unsaved local edits win over concurrent remote changes (last write wins on flush), and echoes of our own debounced persists never remount inputs mid-typing. ## Tests - `tool-executor.service.spec.ts`: auth context exposed to static tools, provided-context reuse, no-identity passthrough, no context leakage after dispatch, CRUD receives the resolved context, CRUD still rejects without identity. - `usePersistedCampaignDraft.test.tsx`: adopt-when-pristine, own-echo stability, dirty-draft-wins, adopt-after-persist. - `lint:diff-with-main` and `typecheck` clean on both packages. --- _Generated by [Claude Code](https://claude.ai/code/session_018nGvGhFahw1pcefb3P4iCk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23811?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+4
-1
@@ -28,7 +28,9 @@ type CampaignBodyFieldProps = {
|
||||
};
|
||||
|
||||
export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => {
|
||||
const { body, setBody, flush } = useCampaignBodyState({ campaign });
|
||||
const { body, setBody, flush, draftResyncKey } = useCampaignBodyState({
|
||||
campaign,
|
||||
});
|
||||
const setActiveEmailEditor = useSetAtomState(activeEmailEditorState);
|
||||
const { uploadEmailImage } = useUploadEmailImage();
|
||||
const { variables } = useCampaignEmailEditorVariables();
|
||||
@@ -46,6 +48,7 @@ export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => {
|
||||
return (
|
||||
<StyledContainer onBlur={() => flush()}>
|
||||
<FormAdvancedTextFieldInput
|
||||
key={draftResyncKey}
|
||||
defaultValue={body}
|
||||
onChange={setBody}
|
||||
placeholder={t`Type something or press "/" to see commands`}
|
||||
|
||||
+2
@@ -109,6 +109,7 @@ export const CampaignDetailsFields = ({
|
||||
return (
|
||||
<StyledFieldsContainer onBlur={() => detailsState.flush()}>
|
||||
<FormTextFieldInput
|
||||
key={`subject-${detailsState.draftResyncKey}`}
|
||||
label={t`Subject`}
|
||||
defaultValue={detailsState.subject}
|
||||
onChange={detailsState.setSubject}
|
||||
@@ -124,6 +125,7 @@ export const CampaignDetailsFields = ({
|
||||
onChange={detailsState.setFromAddress}
|
||||
/>
|
||||
<FormSingleRecordPicker
|
||||
key={`list-${detailsState.draftResyncKey}`}
|
||||
label={t`To`}
|
||||
objectNameSingulars={[CoreObjectNameSingular.MessageList]}
|
||||
defaultValue={detailsState.listId}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { usePersistedCampaignDraft } from '@/activities/emails/hooks/usePersistedCampaignDraft';
|
||||
|
||||
const mockUpdateOneRecord = jest.fn().mockResolvedValue({});
|
||||
const mockEnqueueErrorSnackBar = jest.fn();
|
||||
|
||||
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
|
||||
useUpdateOneRecord: () => ({ updateOneRecord: mockUpdateOneRecord }),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({ enqueueErrorSnackBar: mockEnqueueErrorSnackBar }),
|
||||
}));
|
||||
|
||||
const campaignId = '20202020-0000-4000-8000-000000000001';
|
||||
|
||||
const renderDraftHook = (initialSubject: string) =>
|
||||
renderHook(
|
||||
({ subject }: { subject: string }) =>
|
||||
usePersistedCampaignDraft({
|
||||
campaignId,
|
||||
initialDraft: () => ({ subject }),
|
||||
toUpdateOneRecordInput: (draft) => draft,
|
||||
}),
|
||||
{ initialProps: { subject: initialSubject } },
|
||||
);
|
||||
|
||||
describe('usePersistedCampaignDraft', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should adopt a remote change when the draft is pristine', () => {
|
||||
const { result, rerender } = renderDraftHook('');
|
||||
|
||||
const initialResyncKey = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Written by the AI' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Written by the AI');
|
||||
expect(result.current.draftResyncKey).not.toBe(initialResyncKey);
|
||||
});
|
||||
|
||||
it('should keep the local draft when its own persist echoes back', () => {
|
||||
const { result, rerender } = renderDraftHook('');
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Typed locally' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
|
||||
objectNameSingular: 'messageCampaign',
|
||||
idToUpdate: campaignId,
|
||||
updateOneRecordInput: { subject: 'Typed locally' },
|
||||
});
|
||||
|
||||
const resyncKeyBeforeEcho = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Typed locally' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Typed locally');
|
||||
expect(result.current.draftResyncKey).toBe(resyncKeyBeforeEcho);
|
||||
});
|
||||
|
||||
it('should let unsaved local edits win over a concurrent remote change', () => {
|
||||
const { result, rerender } = renderDraftHook('');
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Typed locally' });
|
||||
});
|
||||
|
||||
const resyncKeyBeforeRemoteChange = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Concurrent remote change' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Typed locally');
|
||||
expect(result.current.draftResyncKey).toBe(resyncKeyBeforeRemoteChange);
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
|
||||
objectNameSingular: 'messageCampaign',
|
||||
idToUpdate: campaignId,
|
||||
updateOneRecordInput: { subject: 'Typed locally' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should adopt a remote change arriving after local edits were persisted and echoed', () => {
|
||||
const { result, rerender } = renderDraftHook('');
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Typed locally' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
rerender({ subject: 'Typed locally' });
|
||||
|
||||
const resyncKeyAfterEcho = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Updated by the AI afterwards' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Updated by the AI afterwards');
|
||||
expect(result.current.draftResyncKey).not.toBe(resyncKeyAfterEcho);
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,17 @@ export const useCampaignBodyState = ({
|
||||
}: {
|
||||
campaign: MessageCampaign;
|
||||
}) => {
|
||||
const { draft, updateDraft, flush } = usePersistedCampaignDraft({
|
||||
campaignId: campaign.id,
|
||||
initialDraft: () => ({ bodyTemplate: campaign.bodyTemplate ?? '' }),
|
||||
toUpdateOneRecordInput: ({ bodyTemplate }) => ({ bodyTemplate }),
|
||||
});
|
||||
const { draft, updateDraft, flush, draftResyncKey } =
|
||||
usePersistedCampaignDraft({
|
||||
campaignId: campaign.id,
|
||||
initialDraft: () => ({ bodyTemplate: campaign.bodyTemplate ?? '' }),
|
||||
toUpdateOneRecordInput: ({ bodyTemplate }) => ({ bodyTemplate }),
|
||||
});
|
||||
|
||||
return {
|
||||
body: draft.bodyTemplate,
|
||||
setBody: (bodyTemplate: string) => updateDraft({ bodyTemplate }),
|
||||
flush,
|
||||
draftResyncKey,
|
||||
};
|
||||
};
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ export const useCampaignDetailsState = ({
|
||||
}: {
|
||||
campaign: MessageCampaign;
|
||||
}) => {
|
||||
const { draft, updateDraft, flush } =
|
||||
const { draft, updateDraft, flush, draftResyncKey } =
|
||||
usePersistedCampaignDraft<CampaignDetailsDraft>({
|
||||
campaignId: campaign.id,
|
||||
initialDraft: () => ({
|
||||
@@ -36,6 +36,7 @@ export const useCampaignDetailsState = ({
|
||||
return {
|
||||
...draft,
|
||||
flush,
|
||||
draftResyncKey,
|
||||
setListId: (listId: string | null) => updateDraft({ listId }),
|
||||
setUnsubscribeTopicId: (unsubscribeTopicId: string | null) =>
|
||||
updateDraft({ unsubscribeTopicId }),
|
||||
|
||||
+20
-22
@@ -1,14 +1,11 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { type MessageCampaign } from '@/activities/emails/types/MessageCampaign';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useRecordSeededDraft } from '@/object-record/record-seeded-draft/hooks/useRecordSeededDraft';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
type UsePersistedCampaignDraftArgs<TDraft extends object> = {
|
||||
campaignId: string;
|
||||
initialDraft: () => TDraft;
|
||||
@@ -22,27 +19,28 @@ export const usePersistedCampaignDraft = <TDraft extends object>({
|
||||
initialDraft,
|
||||
toUpdateOneRecordInput,
|
||||
}: UsePersistedCampaignDraftArgs<TDraft>) => {
|
||||
const [draft, setDraft] = useState<TDraft>(initialDraft);
|
||||
|
||||
const { updateOneRecord } = useUpdateOneRecord();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const persistDebounced = useDebouncedCallback((nextDraft: TDraft) => {
|
||||
updateOneRecord<MessageCampaign>({
|
||||
objectNameSingular: CoreObjectNameSingular.MessageCampaign,
|
||||
idToUpdate: campaignId,
|
||||
updateOneRecordInput: toUpdateOneRecordInput(nextDraft),
|
||||
}).catch(() =>
|
||||
enqueueErrorSnackBar({ message: t`Failed to save the campaign` }),
|
||||
);
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
const { draft, updateDraft, flush, draftResyncKey } = useRecordSeededDraft({
|
||||
upstreamDraft: initialDraft(),
|
||||
onPersist: (nextDraft) => {
|
||||
updateOneRecord<MessageCampaign>({
|
||||
objectNameSingular: CoreObjectNameSingular.MessageCampaign,
|
||||
idToUpdate: campaignId,
|
||||
updateOneRecordInput: toUpdateOneRecordInput(nextDraft),
|
||||
}).catch(() =>
|
||||
enqueueErrorSnackBar({ message: t`Failed to save the campaign` }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const updateDraft = (partialDraft: Partial<TDraft>) => {
|
||||
const nextDraft = { ...draft, ...partialDraft };
|
||||
|
||||
setDraft(nextDraft);
|
||||
persistDebounced(nextDraft);
|
||||
return {
|
||||
draft,
|
||||
updateDraft,
|
||||
flush,
|
||||
// Inputs seeded through defaultValue (TipTap editors, record picker) read
|
||||
// the draft on mount only; key them with this to remount on adoption.
|
||||
draftResyncKey: `${campaignId}-${draftResyncKey}`,
|
||||
};
|
||||
|
||||
return { draft, updateDraft, flush: persistDebounced.flush };
|
||||
};
|
||||
|
||||
+119
-85
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAtom, useStore } from 'jotai';
|
||||
|
||||
import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
@@ -17,6 +17,7 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useRecordSeededDraft } from '@/object-record/record-seeded-draft/hooks/useRecordSeededDraft';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
@@ -109,83 +110,6 @@ export const RichTextFieldEditor = ({
|
||||
return attachmentAbsoluteURL;
|
||||
};
|
||||
|
||||
const persistBodyDebounced = useDebouncedCallback((blocknote: string) => {
|
||||
if (isRecordFieldReadOnly === true) return;
|
||||
|
||||
if (onPersistBody) {
|
||||
onPersistBody(blocknote);
|
||||
return;
|
||||
}
|
||||
|
||||
updateOneRecord({
|
||||
idToUpdate: recordId,
|
||||
objectNameSingular,
|
||||
updateOneRecordInput: {
|
||||
[fieldName]: {
|
||||
blocknote,
|
||||
markdown: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 300);
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
async (newStringifiedBody: string) => {
|
||||
const oldRecord = store.get(recordStoreFamilyState.atomFamily(recordId));
|
||||
|
||||
store.set(
|
||||
recordStoreFamilyState.atomFamily(recordId),
|
||||
(prev: typeof oldRecord) => ({
|
||||
...prev,
|
||||
id: recordId,
|
||||
[fieldName]: {
|
||||
blocknote: newStringifiedBody,
|
||||
markdown: null,
|
||||
},
|
||||
__typename: prev?.__typename ?? objectNameSingular,
|
||||
}),
|
||||
);
|
||||
|
||||
modifyRecordFromCache({
|
||||
recordId,
|
||||
fieldModifiers: {
|
||||
[fieldName]: () => ({
|
||||
blocknote: newStringifiedBody,
|
||||
markdown: null,
|
||||
}),
|
||||
},
|
||||
cache,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
persistBodyDebounced(prepareBodyWithSignedUrls(newStringifiedBody));
|
||||
|
||||
const oldFieldValue = oldRecord?.[fieldName] as
|
||||
| { blocknote?: string | null }
|
||||
| undefined;
|
||||
|
||||
await syncAttachments(newStringifiedBody, oldFieldValue?.blocknote);
|
||||
},
|
||||
[
|
||||
store,
|
||||
recordId,
|
||||
fieldName,
|
||||
objectNameSingular,
|
||||
cache,
|
||||
objectMetadataItem,
|
||||
persistBodyDebounced,
|
||||
syncAttachments,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500);
|
||||
|
||||
const handleEditorChange = () => {
|
||||
const newStringifiedBody = JSON.stringify(editor.document) ?? '';
|
||||
|
||||
handleBodyChangeDebounced(newStringifiedBody);
|
||||
};
|
||||
|
||||
const fieldValue = isDefined(recordInStore)
|
||||
? (recordInStore as Record<string, { blocknote?: string | null }>)?.[
|
||||
fieldName
|
||||
@@ -223,14 +147,121 @@ export const RichTextFieldEditor = ({
|
||||
fieldName,
|
||||
);
|
||||
|
||||
const [currentRecordId, setCurrentRecordId] = useState(recordId);
|
||||
const { updateDraft, markDirty, flush, draftResyncKey } =
|
||||
useRecordSeededDraft({
|
||||
upstreamDraft: { blocknote: fieldValue?.blocknote ?? '' },
|
||||
persistDebounceMs: 300,
|
||||
resetKey: recordId,
|
||||
onPersist: ({ blocknote }) => {
|
||||
if (isRecordFieldReadOnly === true) return;
|
||||
|
||||
const preparedBlocknote = prepareBodyWithSignedUrls(blocknote);
|
||||
|
||||
if (onPersistBody) {
|
||||
onPersistBody(preparedBlocknote);
|
||||
return;
|
||||
}
|
||||
|
||||
updateOneRecord({
|
||||
idToUpdate: recordId,
|
||||
objectNameSingular,
|
||||
updateOneRecordInput: {
|
||||
[fieldName]: {
|
||||
blocknote: preparedBlocknote,
|
||||
markdown: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// The BlockNote editor is uncontrolled; when a remote value is adopted,
|
||||
// replace its content in place instead of remounting to keep the instance.
|
||||
const [lastAppliedResyncKey, setLastAppliedResyncKey] =
|
||||
useState(draftResyncKey);
|
||||
|
||||
// The editor reports programmatic replacements through the same change
|
||||
// callback as typing, so latch around the adoption: without it the adopted
|
||||
// body would be treated as a local edit, marked dirty and written straight
|
||||
// back, blocking the next remote update from being adopted.
|
||||
// oxlint-disable-next-line twenty/no-state-useref
|
||||
const isApplyingUpstreamBodyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRecordId !== recordId) {
|
||||
replaceBlockEditorContent(recordId);
|
||||
setCurrentRecordId(recordId);
|
||||
if (draftResyncKey === lastAppliedResyncKey) {
|
||||
return;
|
||||
}
|
||||
}, [recordId, currentRecordId, replaceBlockEditorContent]);
|
||||
|
||||
setLastAppliedResyncKey(draftResyncKey);
|
||||
|
||||
isApplyingUpstreamBodyRef.current = true;
|
||||
try {
|
||||
replaceBlockEditorContent(recordId);
|
||||
} finally {
|
||||
isApplyingUpstreamBodyRef.current = false;
|
||||
}
|
||||
}, [
|
||||
draftResyncKey,
|
||||
lastAppliedResyncKey,
|
||||
replaceBlockEditorContent,
|
||||
recordId,
|
||||
]);
|
||||
|
||||
const handleBodyChange = async (newStringifiedBody: string) => {
|
||||
const oldRecord = store.get(recordStoreFamilyState.atomFamily(recordId));
|
||||
|
||||
store.set(
|
||||
recordStoreFamilyState.atomFamily(recordId),
|
||||
(prev: typeof oldRecord) => ({
|
||||
...prev,
|
||||
id: recordId,
|
||||
[fieldName]: {
|
||||
blocknote: newStringifiedBody,
|
||||
markdown: null,
|
||||
},
|
||||
__typename: prev?.__typename ?? objectNameSingular,
|
||||
}),
|
||||
);
|
||||
|
||||
modifyRecordFromCache({
|
||||
recordId,
|
||||
fieldModifiers: {
|
||||
[fieldName]: () => ({
|
||||
blocknote: newStringifiedBody,
|
||||
markdown: null,
|
||||
}),
|
||||
},
|
||||
cache,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const oldFieldValue = oldRecord?.[fieldName] as
|
||||
| { blocknote?: string | null }
|
||||
| undefined;
|
||||
|
||||
// Only schedule the persist once the pre-edit body is captured above:
|
||||
// persisting optimistically rewrites the record, and doing that earlier
|
||||
// would make the attachment diff below compare the new body with itself,
|
||||
// leaving attachments removed from the body undeleted.
|
||||
updateDraft({ blocknote: newStringifiedBody });
|
||||
|
||||
await syncAttachments(newStringifiedBody, oldFieldValue?.blocknote);
|
||||
};
|
||||
|
||||
const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500);
|
||||
|
||||
const handleEditorChange = () => {
|
||||
if (isApplyingUpstreamBodyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialization is debounced, so mark the draft dirty synchronously: a
|
||||
// remote adoption arriving in that window would otherwise replace content
|
||||
// the user is actively typing.
|
||||
markDirty();
|
||||
|
||||
handleBodyChangeDebounced(JSON.stringify(editor.document) ?? '');
|
||||
};
|
||||
|
||||
useHotkeysOnFocusedElement({
|
||||
keys: Key.Escape,
|
||||
@@ -257,14 +288,17 @@ export const RichTextFieldEditor = ({
|
||||
});
|
||||
}, [focusId, pushFocusItemToFocusStack, onFocusOverride]);
|
||||
|
||||
const handleBlockEditorBlur = useCallback(() => {
|
||||
const handleBlockEditorBlur = () => {
|
||||
handleBodyChangeDebounced.flush();
|
||||
flush();
|
||||
|
||||
if (onBlurOverride) {
|
||||
onBlurOverride();
|
||||
return;
|
||||
}
|
||||
|
||||
removeFocusItemFromFocusStackById({ focusId });
|
||||
}, [focusId, removeFocusItemFromFocusStackById, onBlurOverride]);
|
||||
};
|
||||
|
||||
return (
|
||||
<BlockEditor
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useRecordSeededDraft } from '@/object-record/record-seeded-draft/hooks/useRecordSeededDraft';
|
||||
|
||||
type DraftProps = {
|
||||
subject: string;
|
||||
resetKey?: string;
|
||||
};
|
||||
|
||||
const mockPersist = jest.fn();
|
||||
|
||||
const renderDraftHook = (initialProps: DraftProps) =>
|
||||
renderHook(
|
||||
({ subject, resetKey }: DraftProps) =>
|
||||
useRecordSeededDraft({
|
||||
upstreamDraft: { subject },
|
||||
onPersist: mockPersist,
|
||||
resetKey,
|
||||
}),
|
||||
{ initialProps },
|
||||
);
|
||||
|
||||
describe('useRecordSeededDraft', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should adopt a remote change when the draft is pristine', () => {
|
||||
const { result, rerender } = renderDraftHook({ subject: '' });
|
||||
|
||||
const initialResyncKey = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Written remotely' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Written remotely');
|
||||
expect(result.current.draftResyncKey).not.toBe(initialResyncKey);
|
||||
});
|
||||
|
||||
it('should keep the local draft when its own persist echoes back', () => {
|
||||
const { result, rerender } = renderDraftHook({ subject: '' });
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Typed locally' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(mockPersist).toHaveBeenCalledWith({ subject: 'Typed locally' });
|
||||
|
||||
const resyncKeyBeforeEcho = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Typed locally' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Typed locally');
|
||||
expect(result.current.draftResyncKey).toBe(resyncKeyBeforeEcho);
|
||||
});
|
||||
|
||||
it('should let unsaved local edits win over a concurrent remote change', () => {
|
||||
const { result, rerender } = renderDraftHook({ subject: '' });
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Typed locally' });
|
||||
});
|
||||
|
||||
rerender({ subject: 'Concurrent remote change' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Typed locally');
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(mockPersist).toHaveBeenCalledWith({ subject: 'Typed locally' });
|
||||
});
|
||||
|
||||
it('should adopt a remote change arriving after local edits were persisted and echoed', () => {
|
||||
const { result, rerender } = renderDraftHook({ subject: '' });
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Typed locally' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
rerender({ subject: 'Typed locally' });
|
||||
|
||||
const resyncKeyAfterEcho = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Updated remotely afterwards' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Updated remotely afterwards');
|
||||
expect(result.current.draftResyncKey).not.toBe(resyncKeyAfterEcho);
|
||||
});
|
||||
|
||||
it('should flush the pending persist on unmount', () => {
|
||||
const { result, unmount } = renderDraftHook({ subject: '' });
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Typed then navigated away' });
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockPersist).toHaveBeenCalledWith({
|
||||
subject: 'Typed then navigated away',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reseed and drop the pending persist when the reset key changes', () => {
|
||||
const { result, rerender } = renderDraftHook({
|
||||
subject: 'First record body',
|
||||
resetKey: 'record-1',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Edited on first record' });
|
||||
});
|
||||
|
||||
const resyncKeyBeforeReset = result.current.draftResyncKey;
|
||||
|
||||
rerender({ subject: 'Second record body', resetKey: 'record-2' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Second record body');
|
||||
expect(result.current.draftResyncKey).not.toBe(resyncKeyBeforeReset);
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(mockPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should drop a pending persist when the reset key cycles back within the debounce', () => {
|
||||
const { result, rerender } = renderDraftHook({
|
||||
subject: 'Record A body',
|
||||
resetKey: 'record-a',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Edited on record A' });
|
||||
});
|
||||
|
||||
rerender({ subject: 'Record B body', resetKey: 'record-b' });
|
||||
rerender({ subject: 'Record A body', resetKey: 'record-a' });
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(mockPersist).not.toHaveBeenCalled();
|
||||
expect(result.current.draft.subject).toBe('Record A body');
|
||||
});
|
||||
|
||||
it('should hold off adoption between markDirty and the serialized update', () => {
|
||||
const { result, rerender } = renderDraftHook({ subject: 'Initial' });
|
||||
|
||||
act(() => {
|
||||
result.current.markDirty();
|
||||
});
|
||||
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
|
||||
rerender({ subject: 'Remote change during serialization' });
|
||||
|
||||
expect(result.current.draft.subject).toBe('Initial');
|
||||
|
||||
act(() => {
|
||||
result.current.updateDraft({ subject: 'Serialized local edit' });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(mockPersist).toHaveBeenCalledWith({
|
||||
subject: 'Serialized local edit',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a pristine draft as not dirty', () => {
|
||||
const { result, rerender } = renderDraftHook({ subject: 'Initial' });
|
||||
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
|
||||
rerender({ subject: 'Remote change' });
|
||||
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
type UseRecordSeededDraftArgs<TDraft extends object> = {
|
||||
// Derived from the record on every render; the single source of remote truth.
|
||||
upstreamDraft: TDraft;
|
||||
onPersist: (draft: TDraft) => void;
|
||||
persistDebounceMs?: number;
|
||||
// Identity of the record being edited. When it changes, the draft is
|
||||
// reseeded from upstream and any pending persist is dropped so it cannot
|
||||
// write the previous record's content onto the new one. Consumers that
|
||||
// remount on record change (key={recordId}) do not need it.
|
||||
resetKey?: string;
|
||||
};
|
||||
|
||||
type ScheduledPersist<TDraft> = {
|
||||
draftToPersist: TDraft;
|
||||
scheduledResetGeneration: number;
|
||||
};
|
||||
|
||||
// Editing state seeded from a record, kept live against remote changes.
|
||||
//
|
||||
// Record data flows into local editing state exactly once per seed, so
|
||||
// changes persisted by someone else (the AI chat, another user, another tab)
|
||||
// arrive through the record without ever reaching the draft. This hook owns
|
||||
// the policy for that seam, the same way on every surface:
|
||||
// - a pristine draft adopts the remote value as soon as it arrives;
|
||||
// - a dirty draft wins, and overwrites the remote value when its debounced
|
||||
// persist flushes (last write wins);
|
||||
// - our own persists come back as an upstream value equal to the draft and
|
||||
// only mark the draft pristine again, never disrupting typing.
|
||||
//
|
||||
// Controlled inputs re-render from `draft`. Uncontrolled inputs remount via
|
||||
// `draftResyncKey`. Imperative editors (BlockNote) watch `draftResyncKey` and
|
||||
// replace their content when it changes; those that debounce their own
|
||||
// serialization call `markDirty` on the raw change so the gap before the
|
||||
// serialized value reaches `updateDraft` is never mistaken for pristine.
|
||||
export const useRecordSeededDraft = <TDraft extends object>({
|
||||
upstreamDraft,
|
||||
onPersist,
|
||||
persistDebounceMs = PERSIST_DEBOUNCE_MS,
|
||||
resetKey,
|
||||
}: UseRecordSeededDraftArgs<TDraft>) => {
|
||||
const [draft, setDraft] = useState<TDraft>(upstreamDraft);
|
||||
const [lastUpstreamDraft, setLastUpstreamDraft] =
|
||||
useState<TDraft>(upstreamDraft);
|
||||
const [lastResetKey, setLastResetKey] = useState(resetKey);
|
||||
const [resyncCount, setResyncCount] = useState(0);
|
||||
const [resetGeneration, setResetGeneration] = useState(0);
|
||||
const [hasUncommittedEdit, setHasUncommittedEdit] = useState(false);
|
||||
|
||||
// The reset check below runs during render, where cancelling the timer
|
||||
// would be an unsafe side effect; instead each scheduled persist remembers
|
||||
// the reset generation it was scheduled under and is dropped once that
|
||||
// generation is over. A counter rather than the key itself, so returning to
|
||||
// a previously edited record (A to B back to A) still drops A's first
|
||||
// pending persist instead of letting it land on the reseeded draft.
|
||||
const persistDebounced = useDebouncedCallback(
|
||||
({
|
||||
draftToPersist,
|
||||
scheduledResetGeneration,
|
||||
}: ScheduledPersist<TDraft>) => {
|
||||
if (scheduledResetGeneration !== resetGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPersist(draftToPersist);
|
||||
},
|
||||
persistDebounceMs,
|
||||
);
|
||||
|
||||
if (resetKey !== lastResetKey) {
|
||||
setLastResetKey(resetKey);
|
||||
setLastUpstreamDraft(upstreamDraft);
|
||||
setDraft(upstreamDraft);
|
||||
setResyncCount((count) => count + 1);
|
||||
setResetGeneration((generation) => generation + 1);
|
||||
setHasUncommittedEdit(false);
|
||||
} else if (!isDeeplyEqual(upstreamDraft, lastUpstreamDraft)) {
|
||||
const isDraftPristine =
|
||||
!hasUncommittedEdit &&
|
||||
!persistDebounced.isPending() &&
|
||||
isDeeplyEqual(draft, lastUpstreamDraft);
|
||||
|
||||
setLastUpstreamDraft(upstreamDraft);
|
||||
|
||||
if (isDraftPristine) {
|
||||
setDraft(upstreamDraft);
|
||||
setResyncCount((count) => count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing keystrokes must never be lost when the editor goes away.
|
||||
useEffect(() => () => persistDebounced.flush(), [persistDebounced]);
|
||||
|
||||
const updateDraft = (partialDraft: Partial<TDraft>) => {
|
||||
const nextDraft = { ...draft, ...partialDraft };
|
||||
|
||||
setDraft(nextDraft);
|
||||
setHasUncommittedEdit(false);
|
||||
persistDebounced({
|
||||
draftToPersist: nextDraft,
|
||||
scheduledResetGeneration: resetGeneration,
|
||||
});
|
||||
};
|
||||
|
||||
// Holds off adoption for editors whose serialized value only arrives later,
|
||||
// without scheduling a persist of a value we do not have yet.
|
||||
const markDirty = () => {
|
||||
setHasUncommittedEdit(true);
|
||||
};
|
||||
|
||||
return {
|
||||
draft,
|
||||
updateDraft,
|
||||
markDirty,
|
||||
flush: persistDebounced.flush,
|
||||
isDirty:
|
||||
hasUncommittedEdit ||
|
||||
persistDebounced.isPending() ||
|
||||
!isDeeplyEqual(draft, lastUpstreamDraft),
|
||||
draftResyncKey: resyncCount,
|
||||
};
|
||||
};
|
||||
+24
-59
@@ -2,14 +2,13 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
|
||||
import { useRecordSeededDraft } from '@/object-record/record-seeded-draft/hooks/useRecordSeededDraft';
|
||||
import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue';
|
||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
import { useAtomFamilySelectorState } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
@@ -17,6 +16,8 @@ const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 300;
|
||||
|
||||
type FieldWidgetTextEditorProps = {
|
||||
fieldMetadataItem: FieldMetadataItem;
|
||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||
@@ -48,80 +49,44 @@ export const FieldWidgetTextEditor = ({
|
||||
const textAreaId = `field-widget-text-editor-${recordId}-${fieldName}`;
|
||||
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';
|
||||
|
||||
const [draftText, setDraftText] = useState(fieldTextValue);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isDraftDirty, setIsDraftDirty] = useState(false);
|
||||
|
||||
const persistTextDebounced = useDebouncedCallback((text: string) => {
|
||||
if (isRecordFieldReadOnly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateOneRecord({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[fieldName]: text,
|
||||
},
|
||||
});
|
||||
}, 300);
|
||||
|
||||
useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftText(fieldTextValue);
|
||||
setIsDraftDirty(false);
|
||||
}, [fieldTextValue, isFocused]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
setIsDraftDirty(false);
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(text: string) => {
|
||||
const { draft, updateDraft, flush } = useRecordSeededDraft({
|
||||
upstreamDraft: { text: fieldTextValue },
|
||||
persistDebounceMs: PERSIST_DEBOUNCE_MS,
|
||||
resetKey: `${recordId}-${fieldMetadataItem.id}`,
|
||||
onPersist: ({ text }) => {
|
||||
if (isRecordFieldReadOnly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftText(text);
|
||||
setIsDraftDirty(true);
|
||||
setFieldValue(text);
|
||||
|
||||
persistTextDebounced(text);
|
||||
updateOneRecord({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[fieldName]: text,
|
||||
},
|
||||
});
|
||||
},
|
||||
[isRecordFieldReadOnly, persistTextDebounced, setFieldValue],
|
||||
);
|
||||
});
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
|
||||
if (isDraftDirty && isRecordFieldReadOnly !== true) {
|
||||
setFieldValue(draftText);
|
||||
const handleChange = (text: string) => {
|
||||
if (isRecordFieldReadOnly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistTextDebounced.flush();
|
||||
}, [
|
||||
draftText,
|
||||
isDraftDirty,
|
||||
isRecordFieldReadOnly,
|
||||
persistTextDebounced,
|
||||
setFieldValue,
|
||||
]);
|
||||
updateDraft({ text });
|
||||
setFieldValue(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<TextArea
|
||||
textAreaId={textAreaId}
|
||||
value={draftText}
|
||||
value={draft.text}
|
||||
readOnly={isRecordFieldReadOnly}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onBlur={() => flush()}
|
||||
variant="transparent"
|
||||
/>
|
||||
</StyledContainer>
|
||||
|
||||
+32
-61
@@ -8,15 +8,7 @@ import { Repository } from 'typeorm';
|
||||
import { type ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
@@ -33,7 +25,10 @@ import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfa
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { type ToolExecutionRef } from 'src/engine/core-modules/tool-provider/types/tool-execution-ref.type';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { buildRequiredToolAuthContext } from 'src/engine/core-modules/tool-provider/utils/build-required-tool-auth-context.util';
|
||||
import { withResolvedToolAuthContext } from 'src/engine/core-modules/tool-provider/utils/with-resolved-tool-auth-context.util';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
@@ -57,6 +52,8 @@ export class ToolExecutorService {
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
async dispatch(
|
||||
@@ -66,19 +63,36 @@ export class ToolExecutorService {
|
||||
): Promise<ToolOutput> {
|
||||
const safeArgs = args ?? {};
|
||||
|
||||
return withResolvedToolAuthContext(
|
||||
{
|
||||
context,
|
||||
userRepository: this.userRepository,
|
||||
userWorkspaceRepository: this.userWorkspaceRepository,
|
||||
workspaceCacheService: this.workspaceCacheService,
|
||||
},
|
||||
(contextWithAuth) =>
|
||||
this.dispatchByExecutionRef(descriptor, safeArgs, contextWithAuth),
|
||||
);
|
||||
}
|
||||
|
||||
private async dispatchByExecutionRef(
|
||||
descriptor: ToolIndexEntry | ToolDescriptor,
|
||||
args: Record<string, unknown>,
|
||||
context: ToolProviderContext,
|
||||
): Promise<ToolOutput> {
|
||||
switch (descriptor.executionRef.kind) {
|
||||
case 'database_crud':
|
||||
return this.dispatchDatabaseCrud(
|
||||
descriptor.executionRef,
|
||||
safeArgs,
|
||||
args,
|
||||
context,
|
||||
);
|
||||
case 'static':
|
||||
return this.dispatchStaticTool(descriptor, safeArgs, context);
|
||||
return this.dispatchStaticTool(descriptor, args, context);
|
||||
case 'logic_function':
|
||||
return this.dispatchLogicFunction(
|
||||
descriptor.executionRef,
|
||||
safeArgs,
|
||||
args,
|
||||
context,
|
||||
);
|
||||
}
|
||||
@@ -90,7 +104,13 @@ export class ToolExecutorService {
|
||||
context: ToolProviderContext,
|
||||
): Promise<ToolOutput> {
|
||||
const authContext =
|
||||
context.authContext ?? (await this.buildAuthContext(context));
|
||||
context.authContext ??
|
||||
(await buildRequiredToolAuthContext({
|
||||
context,
|
||||
userRepository: this.userRepository,
|
||||
userWorkspaceRepository: this.userWorkspaceRepository,
|
||||
workspaceCacheService: this.workspaceCacheService,
|
||||
}));
|
||||
|
||||
switch (ref.operation) {
|
||||
case 'find_many': {
|
||||
@@ -285,53 +305,4 @@ export class ToolExecutorService {
|
||||
result: result.data ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Build authContext on demand for database CRUD operations
|
||||
private async buildAuthContext(
|
||||
context: ToolProviderContext,
|
||||
): Promise<WorkspaceAuthContext> {
|
||||
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
|
||||
throw new AuthException(
|
||||
'userId and userWorkspaceId are required for database operations',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: context.userId },
|
||||
});
|
||||
|
||||
if (!isDefined(user)) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
const { flatWorkspaceMemberMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
|
||||
'flatWorkspaceMemberMaps',
|
||||
]);
|
||||
|
||||
const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id];
|
||||
|
||||
const workspaceMember = isDefined(workspaceMemberId)
|
||||
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) {
|
||||
throw new AuthException(
|
||||
'Workspace member not found',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
return buildUserAuthContext({
|
||||
workspace: { id: context.workspaceId } as FlatWorkspace,
|
||||
userWorkspaceId: context.userWorkspaceId,
|
||||
user: fromUserEntityToFlat(user),
|
||||
workspaceMemberId,
|
||||
workspaceMember,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -15,6 +15,7 @@ import { WebhookToolProvider } from 'src/engine/core-modules/tool-provider/provi
|
||||
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
|
||||
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
|
||||
@@ -68,7 +69,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
|
||||
RoleModule,
|
||||
UserRoleModule,
|
||||
EmailingModule,
|
||||
TypeOrmModule.forFeature([UserEntity]),
|
||||
TypeOrmModule.forFeature([UserEntity, UserWorkspaceEntity]),
|
||||
],
|
||||
providers: [
|
||||
ToolIndexResolver,
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
export type ToolAuthContextDependencies = {
|
||||
userRepository: Pick<Repository<UserEntity>, 'findOne'>;
|
||||
userWorkspaceRepository: Pick<Repository<UserWorkspaceEntity>, 'findOne'>;
|
||||
workspaceCacheService: Pick<WorkspaceCacheService, 'getOrRecompute'>;
|
||||
};
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { buildRequiredToolAuthContext } from 'src/engine/core-modules/tool-provider/utils/build-required-tool-auth-context.util';
|
||||
|
||||
describe('buildRequiredToolAuthContext', () => {
|
||||
const workspaceId = '20202020-0000-4000-8000-000000000001';
|
||||
const userId = '20202020-0000-4000-8000-000000000002';
|
||||
const userWorkspaceId = '20202020-0000-4000-8000-000000000003';
|
||||
const workspaceMemberId = '20202020-0000-4000-8000-000000000004';
|
||||
|
||||
const buildContext = (
|
||||
overrides?: Partial<ToolProviderContext>,
|
||||
): ToolProviderContext => ({
|
||||
workspaceId,
|
||||
roleId: 'role-1',
|
||||
rolePermissionConfig: { unionOf: ['role-1'] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const buildDependencies = () => ({
|
||||
userRepository: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: userId,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
email: 'jane@example.com',
|
||||
isEmailVerified: true,
|
||||
disabled: false,
|
||||
canImpersonate: false,
|
||||
canAccessFullAdminPanel: false,
|
||||
locale: 'en',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
deletedAt: null,
|
||||
}),
|
||||
},
|
||||
userWorkspaceRepository: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: userWorkspaceId,
|
||||
userId,
|
||||
workspaceId,
|
||||
}),
|
||||
},
|
||||
workspaceCacheService: {
|
||||
getOrRecompute: jest.fn().mockResolvedValue({
|
||||
flatWorkspaceMemberMaps: {
|
||||
idByUserId: { [userId]: workspaceMemberId },
|
||||
byId: {
|
||||
[workspaceMemberId]: {
|
||||
id: workspaceMemberId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
it('should build a user auth context from the tool identity', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
const authContext = await buildRequiredToolAuthContext({
|
||||
context: buildContext({ userId, userWorkspaceId }),
|
||||
...dependencies,
|
||||
});
|
||||
|
||||
expect(dependencies.userWorkspaceRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: userWorkspaceId, userId, workspaceId },
|
||||
});
|
||||
expect(authContext).toMatchObject({
|
||||
type: 'user',
|
||||
userWorkspaceId,
|
||||
workspaceMemberId,
|
||||
workspace: { id: workspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw without a resolvable identity', async () => {
|
||||
await expect(
|
||||
buildRequiredToolAuthContext({
|
||||
context: buildContext(),
|
||||
...buildDependencies(),
|
||||
}),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw when the userWorkspace does not bind this user to this workspace', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
dependencies.userWorkspaceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
buildRequiredToolAuthContext({
|
||||
context: buildContext({ userId, userWorkspaceId }),
|
||||
...dependencies,
|
||||
}),
|
||||
).rejects.toThrow('User workspace not found');
|
||||
expect(dependencies.userRepository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when the user cannot be found', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
dependencies.userRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
buildRequiredToolAuthContext({
|
||||
context: buildContext({ userId, userWorkspaceId }),
|
||||
...dependencies,
|
||||
}),
|
||||
).rejects.toThrow('User not found');
|
||||
});
|
||||
|
||||
it('should throw when the user has no workspace member', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
dependencies.workspaceCacheService.getOrRecompute.mockResolvedValue({
|
||||
flatWorkspaceMemberMaps: { idByUserId: {}, byId: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
buildRequiredToolAuthContext({
|
||||
context: buildContext({ userId, userWorkspaceId }),
|
||||
...dependencies,
|
||||
}),
|
||||
).rejects.toThrow('Workspace member not found');
|
||||
});
|
||||
});
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
getWorkspaceAuthContext,
|
||||
workspaceAuthContextStorage,
|
||||
} from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { withResolvedToolAuthContext } from 'src/engine/core-modules/tool-provider/utils/with-resolved-tool-auth-context.util';
|
||||
|
||||
describe('withResolvedToolAuthContext', () => {
|
||||
const workspaceId = '20202020-0000-4000-8000-000000000001';
|
||||
const userId = '20202020-0000-4000-8000-000000000002';
|
||||
const userWorkspaceId = '20202020-0000-4000-8000-000000000003';
|
||||
const workspaceMemberId = '20202020-0000-4000-8000-000000000004';
|
||||
|
||||
const buildContext = (
|
||||
overrides?: Partial<ToolProviderContext>,
|
||||
): ToolProviderContext => ({
|
||||
workspaceId,
|
||||
roleId: 'role-1',
|
||||
rolePermissionConfig: { unionOf: ['role-1'] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildDependencies = () => ({
|
||||
userRepository: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: userId,
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
email: 'jane@example.com',
|
||||
isEmailVerified: true,
|
||||
disabled: false,
|
||||
canImpersonate: false,
|
||||
canAccessFullAdminPanel: false,
|
||||
locale: 'en',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
deletedAt: null,
|
||||
}),
|
||||
},
|
||||
userWorkspaceRepository: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: userWorkspaceId,
|
||||
userId,
|
||||
workspaceId,
|
||||
}),
|
||||
},
|
||||
workspaceCacheService: {
|
||||
getOrRecompute: jest.fn().mockResolvedValue({
|
||||
flatWorkspaceMemberMaps: {
|
||||
idByUserId: { [userId]: workspaceMemberId },
|
||||
byId: {
|
||||
[workspaceMemberId]: {
|
||||
id: workspaceMemberId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should expose a built user auth context to the dispatch via async local storage', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
let storeDuringDispatch: WorkspaceAuthContext | undefined;
|
||||
let contextDuringDispatch: ToolProviderContext | undefined;
|
||||
|
||||
await withResolvedToolAuthContext(
|
||||
{
|
||||
context: buildContext({ userId, userWorkspaceId }),
|
||||
...dependencies,
|
||||
},
|
||||
async (contextWithAuth) => {
|
||||
storeDuringDispatch = workspaceAuthContextStorage.getStore();
|
||||
contextDuringDispatch = contextWithAuth;
|
||||
},
|
||||
);
|
||||
|
||||
expect(dependencies.userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
});
|
||||
expect(storeDuringDispatch).toMatchObject({
|
||||
type: 'user',
|
||||
userWorkspaceId,
|
||||
workspaceMemberId,
|
||||
workspace: { id: workspaceId },
|
||||
});
|
||||
expect(contextDuringDispatch?.authContext).toBe(storeDuringDispatch);
|
||||
});
|
||||
|
||||
it('should reuse a provided auth context without a user lookup', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
const providedAuthContext = {
|
||||
type: 'user',
|
||||
workspace: { id: workspaceId },
|
||||
userWorkspaceId,
|
||||
workspaceMemberId,
|
||||
user: { id: userId },
|
||||
workspaceMember: { id: workspaceMemberId },
|
||||
} as unknown as WorkspaceAuthContext;
|
||||
|
||||
let storeDuringDispatch: WorkspaceAuthContext | undefined;
|
||||
|
||||
await withResolvedToolAuthContext(
|
||||
{
|
||||
context: buildContext({ authContext: providedAuthContext }),
|
||||
...dependencies,
|
||||
},
|
||||
async () => {
|
||||
storeDuringDispatch = workspaceAuthContextStorage.getStore();
|
||||
},
|
||||
);
|
||||
|
||||
expect(dependencies.userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(storeDuringDispatch).toBe(providedAuthContext);
|
||||
});
|
||||
|
||||
it('should run the dispatch outside any auth context when no identity is resolvable', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
const context = buildContext();
|
||||
|
||||
let storeDuringDispatch: WorkspaceAuthContext | undefined | null = null;
|
||||
let contextDuringDispatch: ToolProviderContext | undefined;
|
||||
|
||||
await withResolvedToolAuthContext(
|
||||
{ context, ...dependencies },
|
||||
async (contextWithAuth) => {
|
||||
storeDuringDispatch = workspaceAuthContextStorage.getStore();
|
||||
contextDuringDispatch = contextWithAuth;
|
||||
},
|
||||
);
|
||||
|
||||
expect(dependencies.userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(storeDuringDispatch).toBeUndefined();
|
||||
expect(contextDuringDispatch).toBe(context);
|
||||
});
|
||||
|
||||
it('should not leak the auth context outside the dispatch', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
await withResolvedToolAuthContext(
|
||||
{
|
||||
context: buildContext({ userId, userWorkspaceId }),
|
||||
...dependencies,
|
||||
},
|
||||
async () => {},
|
||||
);
|
||||
|
||||
expect(() => getWorkspaceAuthContext()).toThrow(
|
||||
'Workspace auth context not set',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the dispatch result', async () => {
|
||||
const dependencies = buildDependencies();
|
||||
|
||||
const result = await withResolvedToolAuthContext(
|
||||
{
|
||||
context: buildContext({ userId, userWorkspaceId }),
|
||||
...dependencies,
|
||||
},
|
||||
async () => ({ success: true as const }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolAuthContextDependencies } from 'src/engine/core-modules/tool-provider/types/tool-auth-context-dependencies.type';
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
|
||||
export const buildRequiredToolAuthContext = async ({
|
||||
context,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
workspaceCacheService,
|
||||
}: {
|
||||
context: ToolProviderContext;
|
||||
} & ToolAuthContextDependencies): Promise<WorkspaceAuthContext> => {
|
||||
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
|
||||
throw new AuthException(
|
||||
'userId and userWorkspaceId are required for database operations',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
// The identity triple arrives as separate fields, so validate that the
|
||||
// userWorkspace actually binds this user to this workspace, exactly like
|
||||
// token-based auth does, before building an auth context from it.
|
||||
const userWorkspace = await userWorkspaceRepository.findOne({
|
||||
where: {
|
||||
id: context.userWorkspaceId,
|
||||
userId: context.userId,
|
||||
workspaceId: context.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(userWorkspace)) {
|
||||
throw new AuthException(
|
||||
'User workspace not found',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: context.userId },
|
||||
});
|
||||
|
||||
if (!isDefined(user)) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
const { flatWorkspaceMemberMaps } =
|
||||
await workspaceCacheService.getOrRecompute(context.workspaceId, [
|
||||
'flatWorkspaceMemberMaps',
|
||||
]);
|
||||
|
||||
const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id];
|
||||
|
||||
const workspaceMember = isDefined(workspaceMemberId)
|
||||
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) {
|
||||
throw new AuthException(
|
||||
'Workspace member not found',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
|
||||
return buildUserAuthContext({
|
||||
workspace: { id: context.workspaceId } as FlatWorkspace,
|
||||
userWorkspaceId: context.userWorkspaceId,
|
||||
user: fromUserEntityToFlat(user),
|
||||
workspaceMemberId,
|
||||
workspaceMember,
|
||||
});
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { withWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolAuthContextDependencies } from 'src/engine/core-modules/tool-provider/types/tool-auth-context-dependencies.type';
|
||||
import { buildRequiredToolAuthContext } from 'src/engine/core-modules/tool-provider/utils/build-required-tool-auth-context.util';
|
||||
|
||||
// Tools are dispatched from queue workers (AI chat, workflows) as well as
|
||||
// HTTP requests; only the latter get an async-local auth context from
|
||||
// WorkspaceAuthContextMiddleware. Establish it around the dispatch so any
|
||||
// tool code relying on getWorkspaceAuthContext() works on every transport.
|
||||
// Without a resolvable identity the dispatch runs outside any auth context,
|
||||
// as before.
|
||||
export const withResolvedToolAuthContext = async <T>(
|
||||
{
|
||||
context,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
workspaceCacheService,
|
||||
}: { context: ToolProviderContext } & ToolAuthContextDependencies,
|
||||
dispatch: (contextWithAuth: ToolProviderContext) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const authContext =
|
||||
context.authContext ??
|
||||
(isDefined(context.userId) && isDefined(context.userWorkspaceId)
|
||||
? await buildRequiredToolAuthContext({
|
||||
context,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
workspaceCacheService,
|
||||
})
|
||||
: undefined);
|
||||
|
||||
if (!isDefined(authContext)) {
|
||||
return dispatch(context);
|
||||
}
|
||||
|
||||
return await withWorkspaceAuthContext(authContext, () =>
|
||||
dispatch({ ...context, authContext }),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user