Fix author attachment field (#15065)

# Migrate Attachment Author to CreatedBy Field

**Twill Task**: https://twill.ai/twentyhq/ENG/tasks/7

## Summary

This PR implements a migration to transition the `Attachment` object
from using an `author` relation field to using the standard `createdBy`
field, addressing issue
https://github.com/twentyhq/core-team-issues/issues/1594.

## Changes

- **Added migration command**
(`1-8-migrate-attachment-author-to-created-by.command.ts`):
- Migrates existing attachment data to use `createdBy` instead of
`author`
- Ensures data integrity during the transition to the standard field
pattern

- **Updated Attachment workspace entity**:
  - Added `createdBy` relation field to the `Attachment` standard object
  - Registered new field ID in `standard-field-ids.ts` constants

- **Integrated migration into upgrade pipeline**:
  - Added migration module for version 1.8
  - Registered in the main upgrade version command module

This change aligns the `Attachment` object with Twenty's standard field
conventions by using the built-in `createdBy` field instead of a custom
`author` field.

---

Fixes https://github.com/twentyhq/core-team-issues/issues/1594

---------

Co-authored-by: Twill <agent@twill.ai>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
twill-hq[bot]
2025-10-21 09:56:28 +02:00
committed by GitHub
parent 2903e45bef
commit 187cf400aa
26 changed files with 686 additions and 223 deletions
@@ -0,0 +1,108 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Command({
name: 'upgrade:1-10:migrate-attachment-author-to-created-by',
description:
'Migrate attachment author field data to createdBy composite field',
})
export class MigrateAttachmentAuthorToCreatedByCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Migrating attachment author to createdBy for workspace ${workspaceId}`,
);
const attachmentRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<AttachmentWorkspaceEntity>(
workspaceId,
'attachment',
);
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const attachments = await attachmentRepository.find({
where: {
authorId: Not(IsNull()),
createdBy: {
workspaceMemberId: IsNull(),
},
},
select: ['id', 'authorId'],
});
this.logger.log(
`Found ${attachments.length} attachments to migrate for workspace ${workspaceId}`,
);
let migratedCount = 0;
for (const attachment of attachments) {
const { id, authorId } = attachment;
if (!isDefined(authorId)) {
continue;
}
const workspaceMember = await workspaceMemberRepository.findOne({
where: { id: authorId },
});
if (!isDefined(workspaceMember)) {
this.logger.warn(
`Workspace member ${authorId} not found for attachment ${id}, skipping`,
);
continue;
}
const firstName = workspaceMember.name?.firstName || '';
const lastName = workspaceMember.name?.lastName || '';
const displayName =
firstName || lastName ? `${firstName} ${lastName}`.trim() : 'Unknown';
await attachmentRepository.update(
{ id },
{
createdBy: {
source: FieldActorSource.MANUAL,
workspaceMemberId: workspaceMember.id,
name: displayName,
context: {},
},
},
);
migratedCount++;
}
this.logger.log(
`Successfully migrated ${migratedCount} attachments for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,83 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
const TYPE_TO_FILE_CATEGORY_MAPPING: Record<string, string> = {
Archive: 'ARCHIVE',
Audio: 'AUDIO',
Image: 'IMAGE',
Presentation: 'PRESENTATION',
Spreadsheet: 'SPREADSHEET',
TextDocument: 'TEXT_DOCUMENT',
Video: 'VIDEO',
Other: 'OTHER',
};
@Command({
name: 'upgrade:1-10:migrate-attachment-type-to-file-category',
description:
'Migrate attachment type field data to fileCategory SELECT field',
})
export class MigrateAttachmentTypeToFileCategoryCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Migrating attachment type to fileCategory for workspace ${workspaceId}`,
);
const attachmentRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<AttachmentWorkspaceEntity>(
workspaceId,
'attachment',
);
const attachments = await attachmentRepository.find({
select: ['id', 'type'],
});
this.logger.log(
`Found ${attachments.length} attachments to migrate for workspace ${workspaceId}`,
);
let migratedCount = 0;
for (const attachment of attachments) {
const { id, type } = attachment;
const fileCategory =
TYPE_TO_FILE_CATEGORY_MAPPING[type] ||
TYPE_TO_FILE_CATEGORY_MAPPING.Other;
await attachmentRepository.update(
{ id },
{
fileCategory,
},
);
migratedCount++;
}
this.logger.log(
`Successfully migrated ${migratedCount} attachments for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MigrateAttachmentAuthorToCreatedByCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-author-to-created-by.command';
import { MigrateAttachmentTypeToFileCategoryCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-type-to-file-category.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace]),
WorkspaceSchemaManagerModule,
],
providers: [
MigrateAttachmentAuthorToCreatedByCommand,
MigrateAttachmentTypeToFileCategoryCommand,
],
exports: [
MigrateAttachmentAuthorToCreatedByCommand,
MigrateAttachmentTypeToFileCategoryCommand,
],
})
export class V1_10_UpgradeVersionCommandModule {}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-deduplicate-unique-fields.command';
import { FillNullServerlessFunctionLayerIdCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-fill-null-serverless-function-layer-id.command';
import { MigrateChannelSyncStagesCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-migrate-channel-sync-stages.command';
import { MigrateWorkflowStepFilterOperandValueCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-migrate-workflow-step-filter-operand-value';
import { RegeneratePersonSearchVectorWithPhonesCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-regenerate-person-search-vector-with-phones.command';
@@ -10,12 +11,11 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { FillNullServerlessFunctionLayerIdCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-fill-null-serverless-function-layer-id.command';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
@Module({
imports: [
@@ -10,6 +10,7 @@ import { V1_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V1_6_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module';
import { V1_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module';
import { V1_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-8/1-8-upgrade-version-command.module';
import { V1_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-10/1-10-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
@@ -26,6 +27,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
V1_6_UpgradeVersionCommandModule,
V1_7_UpgradeVersionCommandModule,
V1_8_UpgradeVersionCommandModule,
V1_10_UpgradeVersionCommandModule,
WorkspaceSyncMetadataModule,
],
providers: [UpgradeCommand],
@@ -19,6 +19,8 @@ import { AddEnqueuedStatusToWorkflowRunCommand } from 'src/database/commands/upg
import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command';
import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command';
import { MigrateWorkflowRunStatesCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-migrate-workflow-run-state.command';
import { MigrateAttachmentAuthorToCreatedByCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-author-to-created-by.command';
import { MigrateAttachmentTypeToFileCategoryCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-type-to-file-category.command';
import { AddEnqueuedStatusToWorkflowRunV2Command } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-enqueued-status-to-workflow-run-v2.command';
import { AddNextStepIdsToWorkflowVersionTriggers } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-next-step-ids-to-workflow-version-triggers.command';
import { RemoveWorkflowRunsWithoutState } from 'src/database/commands/upgrade-version-command/1-2/1-2-remove-workflow-runs-without-state.command';
@@ -30,6 +32,7 @@ import { RemoveFavoriteViewRelationCommand } from 'src/database/commands/upgrade
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-deduplicate-unique-fields.command';
import { FillNullServerlessFunctionLayerIdCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-fill-null-serverless-function-layer-id.command';
import { MigrateChannelSyncStagesCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-migrate-channel-sync-stages.command';
import { MigrateWorkflowStepFilterOperandValueCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-migrate-workflow-step-filter-operand-value';
import { RegeneratePersonSearchVectorWithPhonesCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-regenerate-person-search-vector-with-phones.command';
@@ -37,7 +40,6 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
import { FillNullServerlessFunctionLayerIdCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-fill-null-serverless-function-layer-id.command';
@Command({
name: 'upgrade',
@@ -90,11 +92,15 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillWorkflowManualTriggerAvailabilityCommand: BackfillWorkflowManualTriggerAvailabilityCommand,
// 1.8 Commands
protected readonly fillNullServerlessFunctionLayerIdCommand: FillNullServerlessFunctionLayerIdCommand,
protected readonly migrateWorkflowStepFilterOperandValueCommand: MigrateWorkflowStepFilterOperandValueCommand,
protected readonly deduplicateUniqueFieldsCommand: DeduplicateUniqueFieldsCommand,
protected readonly regeneratePersonSearchVectorWithPhonesCommand: RegeneratePersonSearchVectorWithPhonesCommand,
protected readonly migrateChannelSyncStagesCommand: MigrateChannelSyncStagesCommand,
protected readonly fillNullServerlessFunctionLayerIdCommand: FillNullServerlessFunctionLayerIdCommand,
// 1.10 Commands
protected readonly migrateAttachmentAuthorToCreatedByCommand: MigrateAttachmentAuthorToCreatedByCommand,
protected readonly migrateAttachmentTypeToFileCategoryCommand: MigrateAttachmentTypeToFileCategoryCommand,
) {
super(
workspaceRepository,
@@ -199,6 +205,14 @@ export class UpgradeCommand extends UpgradeCommandRunner {
afterSyncMetadata: [],
};
const commands_1100: VersionCommands = {
beforeSyncMetadata: [],
afterSyncMetadata: [
this.migrateAttachmentAuthorToCreatedByCommand,
this.migrateAttachmentTypeToFileCategoryCommand,
],
};
this.allCommands = {
'0.53.0': commands_053,
'0.54.0': commands_054,
@@ -213,6 +227,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
'1.6.0': commands_160,
'1.7.0': commands_170,
'1.8.0': commands_180,
'1.10.0': commands_1100,
};
}