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:
Félix Malfait
2026-08-05 20:27:21 +02:00
committed by GitHub
parent dc8c62a7a9
commit c35e364562
17 changed files with 1099 additions and 235 deletions
@@ -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`}
@@ -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}
@@ -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,
};
};
@@ -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 }),
@@ -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 };
};
@@ -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
@@ -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);
});
});
@@ -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,
};
};
@@ -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>
@@ -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,
});
}
}
@@ -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,
@@ -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'>;
};
@@ -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');
});
});
@@ -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 });
});
});
@@ -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,
});
};
@@ -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 }),
);
};