c35e364562
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. -->
159 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
};
|