Files
twenty/packages/twenty-front/src/modules/activities/emails/components/CampaignDetailsFields.tsx
T
Félix Malfait c35e364562 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. -->
2026-08-05 20:27:21 +02:00

159 lines
5.5 KiB
TypeScript

import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import {
CoreObjectNameSingular,
MessageChannelType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useCampaignAudiencePreview } from '@/activities/emails/hooks/useCampaignAudiencePreview';
import { useCampaignDetailsState } from '@/activities/emails/hooks/useCampaignDetailsState';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { type MessageCampaign } from '@/activities/emails/types/MessageCampaign';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { Select } from '@/ui/input/components/Select';
const StyledFieldsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[2]};
`;
const StyledHint = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xs};
padding: ${themeCssVariables.spacing[1]} 0;
`;
type CampaignAudiencePreview = NonNullable<
ReturnType<typeof useCampaignAudiencePreview>
>;
const buildAudienceHint = (preview: CampaignAudiencePreview): string => {
const parts: string[] = [];
if (preview.withoutEmail > 0) {
parts.push(t`${preview.withoutEmail} without email`);
}
if (preview.duplicateEmails > 0) {
parts.push(t`${preview.duplicateEmails} duplicate`);
}
if (preview.globallyUnsubscribed > 0) {
parts.push(t`${preview.globallyUnsubscribed} unsubscribed from everything`);
}
if (preview.topicUnsubscribed > 0) {
parts.push(t`${preview.topicUnsubscribed} opted out of this topic`);
}
if (parts.length === 0) {
return t`${preview.totalMembers} in this list`;
}
const breakdown = parts.join(', ');
// Without exclusions every member is sendable, so the count is only worth
// spelling out when the two differ.
return t`${preview.totalMembers} in this list, ${preview.sendable} sendable (${breakdown})`;
};
type CampaignDetailsFieldsProps = {
campaign: MessageCampaign;
};
export const CampaignDetailsFields = ({
campaign,
}: CampaignDetailsFieldsProps) => {
const detailsState = useCampaignDetailsState({ campaign });
const { channels } = useMyMessageChannels();
const { unsubscribeTopics } = useUnsubscribeTopics();
const { createOneRecord: createMessageList } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageList,
});
const handleCreateList = async (searchInput?: string) => {
const listName = searchInput?.trim() ?? '';
const createdList = await createMessageList({
name: listName.length > 0 ? listName : t`Untitled list`,
});
if (isDefined(createdList)) {
detailsState.setListId(createdList.id);
}
};
const audiencePreview = useCampaignAudiencePreview({
listId: detailsState.listId,
unsubscribeTopicId: detailsState.unsubscribeTopicId,
});
const senderOptions: SelectOption<string>[] = channels
.filter((channel) => channel.type === MessageChannelType.EMAIL_GROUP)
.map((channel) => channel.connectedAccount?.handle)
.filter(isDefined)
.map((handle) => ({ label: handle, value: handle }));
const topicOptions: SelectOption<string>[] = unsubscribeTopics.map(
(topic) => ({
label: topic.name ?? t`Untitled topic`,
value: topic.id,
}),
);
return (
<StyledFieldsContainer onBlur={() => detailsState.flush()}>
<FormTextFieldInput
key={`subject-${detailsState.draftResyncKey}`}
label={t`Subject`}
defaultValue={detailsState.subject}
onChange={detailsState.setSubject}
placeholder={t`Subject`}
/>
<Select
dropdownId="campaign-composer-from-account"
label={t`From`}
fullWidth
value={detailsState.fromAddress}
options={senderOptions}
emptyOption={{ label: t`Select a sender`, value: '' }}
onChange={detailsState.setFromAddress}
/>
<FormSingleRecordPicker
key={`list-${detailsState.draftResyncKey}`}
label={t`To`}
objectNameSingulars={[CoreObjectNameSingular.MessageList]}
defaultValue={detailsState.listId}
onChange={detailsState.setListId}
onCreate={handleCreateList}
/>
{isDefined(audiencePreview) && (
<StyledHint>{buildAudienceHint(audiencePreview)}</StyledHint>
)}
{topicOptions.length > 0 && (
<>
<Select
dropdownId="campaign-composer-unsubscribe-topic"
label={t`Unsubscribe topic`}
fullWidth
value={detailsState.unsubscribeTopicId ?? ''}
options={topicOptions}
emptyOption={{ label: t`No topic`, value: '' }}
onChange={(value) =>
detailsState.setUnsubscribeTopicId(value === '' ? null : value)
}
/>
<StyledHint>
{t`The unsubscribe topic this email belongs to. Recipients who opted out of it are skipped, and the unsubscribe link is scoped to it.`}
</StyledHint>
</>
)}
</StyledFieldsContainer>
);
};