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:
Thomas Trompette
2026-06-17 17:54:35 +02:00
committed by GitHub
parent 607d9ee6e5
commit 105f9565a5
19 changed files with 629 additions and 50 deletions
@@ -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 {}
@@ -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'
);
}
}
@@ -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,
});
});
});
@@ -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 };
};
@@ -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 {}