Fix messageThread view and labelIdentifier on legacy workspaces (#19414)

## Summary
Adds `upgrade:1-21:fix-message-thread-view-and-label-identifier` to
retroactively apply two messageThread changes from #19351 that never
propagated to existing workspaces (because
`synchronizeTwentyStandardApplicationOrThrow` only runs at workspace
creation):

1. **`allMessageThreads` view fields**: delete-and-recreate all view
fields for the standard view from the current twenty-standard
definition, which adds the new `subject` and `updatedAt` columns. Same
pattern as the FIELDS_WIDGET sync in
`1-21-workspace-command-1775500005000-backfill-page-layouts-and-fields-widget-view-fields`.
2. **`messageThread.labelIdentifierFieldMetadataId`**: repointed to the
`subject` field via `validateBuildAndRunWorkspaceMigration`. The
migration runner already resolves
`labelIdentifierFieldMetadataUniversalIdentifier` → id in
`update-object-action-handler`, so this goes through the standard flow
and properly invalidates caches.

Both fixes share a single `validateBuildAndRunWorkspaceMigration` call
(one `viewField` op, one `objectMetadata` update op). Idempotent — skips
if nothing to do.

Depends on `upgrade:1-21:backfill-message-thread-subject` having run
first (the label identifier fix needs the `subject` field to exist; it
logs a warning and skips that part otherwise).

## Test plan
- [ ] Legacy workspace: run \`backfill-message-thread-subject\`, then
this command, then verify:
- the \`allMessageThreads\` view shows the new \`subject\` and
\`updatedAt\` columns
  - messageThread records display their \`subject\` as the record label
- [ ] Re-run the command: no-op, logs \`Nothing to fix\`
- [ ] \`--dry-run\` logs planned changes without applying them

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-07 21:57:35 +02:00
committed by GitHub
parent e2bd3e8ef4
commit 9bdef449e6
2 changed files with 252 additions and 0 deletions
@@ -15,6 +15,7 @@ import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/comman
import { DropWorkspaceMessagingFksCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500010000-drop-workspace-messaging-fks.command';
import { MigrateMessageFolderParentIdToExternalIdCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500011000-migrate-message-folder-parent-id-to-external-id.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500012000-migrate-messaging-infrastructure-to-metadata.command';
import { FixMessageThreadViewAndLabelIdentifierCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500014000-fix-message-thread-view-and-label-identifier.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -64,6 +65,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
DropWorkspaceMessagingFksCommand,
MigrateMessageFolderParentIdToExternalIdCommand,
MigrateMessagingInfrastructureToMetadataCommand,
FixMessageThreadViewAndLabelIdentifierCommand,
],
})
export class V1_21_UpgradeVersionCommandModule {}
@@ -0,0 +1,250 @@
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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
const MESSAGE_THREAD_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.messageThread.universalIdentifier;
const MESSAGE_THREAD_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.messageThread.fields.subject.universalIdentifier;
const ALL_MESSAGE_THREADS_VIEW_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.messageThread.views.allMessageThreads.universalIdentifier;
type WorkspaceFlatEntityMaps = Awaited<
ReturnType<WorkspaceCacheService['getOrRecompute']>
>;
type FlatViewFieldMaps = WorkspaceFlatEntityMaps['flatViewFieldMaps'];
type FlatFieldMetadataMaps = WorkspaceFlatEntityMaps['flatFieldMetadataMaps'];
const filterViewFieldsByViewUniversalIdentifier = ({
flatViewFieldMaps,
viewUniversalIdentifier,
}: {
flatViewFieldMaps: FlatViewFieldMaps;
viewUniversalIdentifier: string;
}): UniversalFlatViewField[] =>
Object.values(flatViewFieldMaps.byUniversalIdentifier)
.filter(isDefined)
.filter(
(viewField) =>
viewField.viewUniversalIdentifier === viewUniversalIdentifier,
);
@RegisteredWorkspaceCommand('1.21.0', 1775500014000)
@Command({
name: 'upgrade:1-21:fix-message-thread-view-and-label-identifier',
description:
'Sync the allMessageThreads standard view fields with the current standard definition (adds subject and updatedAt columns) and repoint messageThread.labelIdentifierFieldMetadataId to the subject field. Fixes workspaces upgraded from <1.21 where PR #19351 changes were not applied because the twenty-standard application is not re-synced on existing workspaces.',
})
export class FixMessageThreadViewAndLabelIdentifierCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatViewFieldMaps: existingFlatViewFieldMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatViewFieldMaps',
]);
const existingMessageThreadObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: existingFlatObjectMetadataMaps,
universalIdentifier: MESSAGE_THREAD_OBJECT_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(existingMessageThreadObjectMetadata)) {
this.logger.log(
`messageThread object metadata not found for workspace ${workspaceId}, skipping`,
);
return;
}
const viewFieldsToDelete = this.computeMessageThreadViewFieldsToDelete({
existingFlatViewFieldMaps,
});
const viewFieldsToCreate = this.computeMessageThreadViewFieldsToCreate({
standardAllFlatEntityMaps,
existingFlatFieldMetadataMaps,
});
const flatObjectMetadataToUpdate =
this.computeMessageThreadObjectMetadataToUpdate({
existingMessageThreadObjectMetadata,
existingFlatFieldMetadataMaps,
});
const hasViewFieldChanges =
viewFieldsToDelete.length > 0 || viewFieldsToCreate.length > 0;
const hasObjectMetadataChange = isDefined(flatObjectMetadataToUpdate);
if (!hasViewFieldChanges && !hasObjectMetadataChange) {
this.logger.log(
`Nothing to fix for messageThread in workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Fixing messageThread in workspace ${workspaceId}: deleting ${viewFieldsToDelete.length} view fields, creating ${viewFieldsToCreate.length} view fields${hasObjectMetadataChange ? ', repointing labelIdentifierFieldMetadataId to subject' : ''}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
objectMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: hasObjectMetadataChange
? [flatObjectMetadataToUpdate]
: [],
},
viewField: {
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: viewFieldsToDelete,
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to fix messageThread for workspace ${workspaceId}:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to fix messageThread for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully fixed messageThread for workspace ${workspaceId}`,
);
}
// Delete every existing view field attached to the standard allMessageThreads view
// so we can recreate them from the current standard definition in one diff.
private computeMessageThreadViewFieldsToDelete({
existingFlatViewFieldMaps,
}: {
existingFlatViewFieldMaps: FlatViewFieldMaps;
}): UniversalFlatViewField[] {
return filterViewFieldsByViewUniversalIdentifier({
flatViewFieldMaps: existingFlatViewFieldMaps,
viewUniversalIdentifier: ALL_MESSAGE_THREADS_VIEW_UNIVERSAL_IDENTIFIER,
});
}
// Recreate view fields from the current standard definition, skipping any that
// reference a field metadata that does not yet exist in this workspace (defensive
// guard in case the 1-21 subject backfill command has not been run first).
private computeMessageThreadViewFieldsToCreate({
standardAllFlatEntityMaps,
existingFlatFieldMetadataMaps,
}: {
standardAllFlatEntityMaps: ReturnType<
typeof computeTwentyStandardApplicationAllFlatEntityMaps
>['allFlatEntityMaps'];
existingFlatFieldMetadataMaps: FlatFieldMetadataMaps;
}): UniversalFlatViewField[] {
return filterViewFieldsByViewUniversalIdentifier({
flatViewFieldMaps: standardAllFlatEntityMaps.flatViewFieldMaps,
viewUniversalIdentifier: ALL_MESSAGE_THREADS_VIEW_UNIVERSAL_IDENTIFIER,
}).filter((viewField) =>
isDefined(
existingFlatFieldMetadataMaps.byUniversalIdentifier[
viewField.fieldMetadataUniversalIdentifier
],
),
);
}
// Build the "to" messageThread object metadata with its labelIdentifier repointed
// to the subject field. Returns undefined if the subject field is missing (the
// 1-21 subject backfill command must be run first) or if the pointer is already
// correct, in which case we skip the update to avoid a no-op migration.
private computeMessageThreadObjectMetadataToUpdate({
existingMessageThreadObjectMetadata,
existingFlatFieldMetadataMaps,
}: {
existingMessageThreadObjectMetadata: FlatObjectMetadata;
existingFlatFieldMetadataMaps: FlatFieldMetadataMaps;
}): UniversalFlatObjectMetadata | undefined {
const existingSubjectField = findFlatEntityByUniversalIdentifier({
flatEntityMaps: existingFlatFieldMetadataMaps,
universalIdentifier: MESSAGE_THREAD_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(existingSubjectField)) {
this.logger.warn(
`messageThread.subject field not found in workspace - run upgrade:1-21:backfill-message-thread-subject first`,
);
return undefined;
}
if (
existingMessageThreadObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier ===
MESSAGE_THREAD_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER
) {
return undefined;
}
return {
...existingMessageThreadObjectMetadata,
labelIdentifierFieldMetadataUniversalIdentifier:
MESSAGE_THREAD_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
};
}
}