feat(workflow): surface manual-trigger payload + metadata in variable picker (#21692)
## Summary Step 2 of the manual-trigger output schema restructuring (expand → display → migrate → contract). Builds on the now-merged #21676 (which expanded the runtime payload to serve `payload` and `metadata` siblings at the trigger root). This PR **surfaces** those in the variable picker as nested, expandable nodes: - `trigger.payload.{record fields}` — the record(s) that triggered the run - `trigger.metadata.workspaceMemberId` — who triggered it The flat root fields (`trigger.id`, etc.) remain available, so existing saved variable references keep working until a later migration phase moves them. ### Changes - **twenty-shared**: metadata/payload label constants + `build-manual-trigger-metadata-node` util + barrel exports. - **twenty-front**: `computeStepOutputSchema` MANUAL branch now nests `payload` (RecordNode for SINGLE_RECORD, array Node for BULK_RECORDS, omitted for GLOBAL) and `metadata`; `ManualTriggerOutputSchema` type updated to `{ payload?; metadata }`. - **twenty-server**: `computeTriggerOutputSchemaFromAvailability` mirrors the same nested shape for server-side validation. The key is `metadata` (not `_metadata`) — custom fields can't start with `_`, so collision risk was deemed acceptable. ## Test plan - [x] `npx nx build twenty-shared` - [x] `computeStepOutputSchema` unit tests pass (55) - [x] Manual: create a manual-trigger workflow (GLOBAL / single-record / bulk), confirm the picker shows `payload` and `metadata` as expandable folders and that selecting a field yields `{{trigger.payload.<field>}}` / `{{trigger.metadata.workspaceMemberId}}` > Note: server typecheck has pre-existing unrelated failures on main (Stripe billing mocks, gmail mocks); none touch workflow files. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21692?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:
+11
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { MigrateManualTriggerVariablesToPayloadCommand } from 'src/database/commands/upgrade-version-command/2-15/2-15-workspace-command-1800000001000-migrate-manual-trigger-variables-to-payload.command';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceIteratorModule, WorkspaceCacheModule],
|
||||
providers: [MigrateManualTriggerVariablesToPayloadCommand],
|
||||
})
|
||||
export class V2_15_UpgradeVersionCommandModule {}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { rewriteTriggerVariablesToPayload } from 'src/database/commands/upgrade-version-command/2-15/utils/rewrite-trigger-variables-to-payload.util';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
@RegisteredWorkspaceCommand('2.15.0', 1800000001000)
|
||||
@Command({
|
||||
name: 'upgrade:2-15:migrate-manual-trigger-variables-to-payload',
|
||||
description:
|
||||
'Rewrite saved {{trigger.<field>}} references to {{trigger.payload.<field>}} for manual record triggers',
|
||||
})
|
||||
export class MigrateManualTriggerVariablesToPayloadCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
// Empty/partially-provisioned workspaces have no workflowVersion object;
|
||||
// fetching the repository for a missing entity throws, so skip them.
|
||||
const { flatObjectMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatObjectMetadataMaps',
|
||||
]);
|
||||
|
||||
const workflowVersionObject =
|
||||
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
universalIdentifier: STANDARD_OBJECTS.workflowVersion.universalIdentifier,
|
||||
});
|
||||
|
||||
if (!isDefined(workflowVersionObject)) {
|
||||
this.logger.log(
|
||||
`workflowVersion object not found for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowVersionRepository =
|
||||
await this.twentyORMGlobalManager.getRepository<WorkflowVersionWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workflowVersion',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const allVersions = await workflowVersionRepository.find();
|
||||
|
||||
let updatedVersionCount = 0;
|
||||
|
||||
for (const version of allVersions) {
|
||||
if (!this.isManualRecordTrigger(version.trigger)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Trigger references only appear in downstream steps, never in the
|
||||
// trigger object itself, so only the steps need rewriting.
|
||||
const migratedSteps = rewriteTriggerVariablesToPayload(version.steps);
|
||||
|
||||
if (!migratedSteps.changed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedVersionCount++;
|
||||
|
||||
if (isDryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await workflowVersionRepository.update(version.id, {
|
||||
steps: migratedSteps.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (updatedVersionCount > 0) {
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Migrated trigger variables in ${updatedVersionCount} workflow version(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isManualRecordTrigger(
|
||||
trigger: WorkflowVersionWorkspaceEntity['trigger'],
|
||||
): boolean {
|
||||
if (!isDefined(trigger) || trigger.type !== WorkflowTriggerType.MANUAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const availabilityType = trigger.settings?.availability?.type;
|
||||
|
||||
return (
|
||||
availabilityType === 'SINGLE_RECORD' || availabilityType === 'BULK_RECORDS'
|
||||
);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { rewriteTriggerVariablesToPayload } from 'src/database/commands/upgrade-version-command/2-15/utils/rewrite-trigger-variables-to-payload.util';
|
||||
|
||||
describe('rewriteTriggerVariablesToPayload', () => {
|
||||
it('rewrites a flat trigger field reference into the payload node', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload({
|
||||
message: 'Hello {{trigger.name}}',
|
||||
});
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(value).toEqual({ message: 'Hello {{trigger.payload.name}}' });
|
||||
});
|
||||
|
||||
it('rewrites composite and nested field paths', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload({
|
||||
to: '{{trigger.email.primaryEmail}}',
|
||||
city: '{{trigger.address.addressCity}}',
|
||||
});
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(value).toEqual({
|
||||
to: '{{trigger.payload.email.primaryEmail}}',
|
||||
city: '{{trigger.payload.address.addressCity}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites bracket-escaped segments', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload(
|
||||
'{{trigger.[My Custom Field]}}',
|
||||
);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(value).toBe('{{trigger.payload.[My Custom Field]}}');
|
||||
});
|
||||
|
||||
it('rewrites every occurrence within a value', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload(
|
||||
'{{trigger.firstName}} {{trigger.lastName}}',
|
||||
);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(value).toBe(
|
||||
'{{trigger.payload.firstName}} {{trigger.payload.lastName}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates a field whose name merely starts with "payload"', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload(
|
||||
'{{trigger.payloadStatus}}',
|
||||
);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(value).toBe('{{trigger.payload.payloadStatus}}');
|
||||
});
|
||||
|
||||
it('is idempotent for already-migrated references', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload(
|
||||
'{{trigger.payload.name}}',
|
||||
);
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect(value).toBe('{{trigger.payload.name}}');
|
||||
});
|
||||
|
||||
it('leaves metadata references untouched', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload(
|
||||
'{{trigger.metadata.workspaceMemberId}}',
|
||||
);
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect(value).toBe('{{trigger.metadata.workspaceMemberId}}');
|
||||
});
|
||||
|
||||
it('leaves the legacy _metadata key untouched', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload(
|
||||
'{{trigger._metadata.workspaceMemberId}}',
|
||||
);
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect(value).toBe('{{trigger._metadata.workspaceMemberId}}');
|
||||
});
|
||||
|
||||
it('does not touch references to other steps', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload({
|
||||
body: '{{a1b2c3.trigger.name}} {{step-2.email}}',
|
||||
});
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect(value).toEqual({
|
||||
body: '{{a1b2c3.trigger.name}} {{step-2.email}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch a bare full-trigger reference', () => {
|
||||
const { value, changed } = rewriteTriggerVariablesToPayload('{{trigger}}');
|
||||
|
||||
expect(changed).toBe(false);
|
||||
expect(value).toBe('{{trigger}}');
|
||||
});
|
||||
|
||||
it('returns null/undefined values unchanged', () => {
|
||||
expect(rewriteTriggerVariablesToPayload(null)).toEqual({
|
||||
value: null,
|
||||
changed: false,
|
||||
});
|
||||
expect(rewriteTriggerVariablesToPayload(undefined)).toEqual({
|
||||
value: undefined,
|
||||
changed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
// Manual-trigger record fields moved from the flat trigger root
|
||||
// ({{trigger.name}}) to a nested payload node ({{trigger.payload.name}}).
|
||||
// The negative lookaheads keep this idempotent: references already pointing at
|
||||
// `payload`/`metadata` (or the legacy `_metadata` key) are left untouched, so
|
||||
// re-running is a no-op and metadata refs are never prefixed with `payload`.
|
||||
// A field named exactly `payload` or `metadata` collides and is skipped — that
|
||||
// risk was accepted when the nested keys were named.
|
||||
const TRIGGER_VARIABLE_PREFIX_REGEX =
|
||||
/\{\{trigger\.(?!payload[.}])(?!metadata[.}])(?!_metadata[.}])/g;
|
||||
|
||||
export const rewriteTriggerVariablesToPayload = <TValue>(
|
||||
value: TValue,
|
||||
): { value: TValue; changed: boolean } => {
|
||||
if (!isDefined(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(value);
|
||||
const rewritten = serialized.replace(
|
||||
TRIGGER_VARIABLE_PREFIX_REGEX,
|
||||
'{{trigger.payload.',
|
||||
);
|
||||
|
||||
if (rewritten === serialized) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
return { value: JSON.parse(rewritten) as TValue, changed: true };
|
||||
};
|
||||
+2
@@ -15,6 +15,7 @@ import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
|
||||
import { V2_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-13/2-13-upgrade-version-command.module';
|
||||
import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-14/2-14-upgrade-version-command.module';
|
||||
import { V2_15_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-15/2-15-upgrade-version-command.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,6 +34,7 @@ import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
|
||||
V2_10_UpgradeVersionCommandModule,
|
||||
V2_13_UpgradeVersionCommandModule,
|
||||
V2_14_UpgradeVersionCommandModule,
|
||||
V2_15_UpgradeVersionCommandModule,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCommandProviderModule {}
|
||||
|
||||
Reference in New Issue
Block a user