Files v2 - Use Files field in attachment (#17707)

- Add FILES field on attachment
- Adapt Attachment logic in front to use new resolver/controller
- Update files-field logic to infer applicationId from fieldMetadataId +
ask for fieldMetadataId in upload resolver
- Design update


To do in next PR : 
- Adapt activity files logic
This commit is contained in:
Etienne
2026-02-05 17:42:19 +01:00
committed by GitHub
parent e382641ac3
commit 77d15356e1
38 changed files with 1905 additions and 1533 deletions
@@ -14,6 +14,7 @@ export enum FeatureFlagKey {
IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED',
IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED',
IS_TASK_TARGET_MIGRATED = 'IS_TASK_TARGET_MIGRATED',
IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED',
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
@@ -12,13 +12,19 @@ import { FilesFieldDeletionListener } from 'src/engine/core-modules/file/files-f
import { FilesFieldResolver } from 'src/engine/core-modules/file/files-field/resolvers/files-field.resolver';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]),
TypeOrmModule.forFeature([
FileEntity,
WorkspaceEntity,
ApplicationEntity,
FieldMetadataEntity,
]),
PermissionsModule,
FileStorageModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
@@ -20,6 +20,7 @@ import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FilesFieldException,
@@ -32,6 +33,8 @@ export class FilesFieldService {
private readonly fileStorageService: FileStorageService,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly twentyConfigService: TwentyConfigService,
@@ -43,13 +46,13 @@ export class FilesFieldService {
filename,
declaredMimeType,
workspaceId,
applicationId,
fieldMetadataId,
}: {
file: Buffer;
filename: string;
declaredMimeType: string | undefined;
workspaceId: string;
applicationId: string;
fieldMetadataId: string;
}): Promise<FileEntity> {
const { mimeType, ext } = await extractFileInfo({
file,
@@ -62,16 +65,24 @@ export class FilesFieldService {
const fileId = v4();
const name = `${fileId}${ext ? `.${ext}` : ''}`;
const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({
select: ['applicationId', 'universalIdentifier'],
where: {
id: fieldMetadataId,
workspaceId,
},
});
const application = await this.applicationRepository.findOneOrFail({
where: {
id: applicationId,
id: fieldMetadata.applicationId,
workspaceId,
},
});
return await this.fileStorageService.writeFile_v2({
sourceFile: sanitizedFile,
resourcePath: name,
resourcePath: `${fieldMetadata.universalIdentifier}/${name}`,
mimeType,
fileFolder: FileFolder.FilesField,
applicationUniversalIdentifier: application.universalIdentifier,
@@ -27,9 +27,15 @@ export class FilesFieldResolver {
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFilesFieldFile(
@AuthWorkspace()
{ id: workspaceId, workspaceCustomApplicationId }: WorkspaceEntity,
{ id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@Args({
name: 'fieldMetadataId',
type: () => String,
nullable: false,
})
fieldMetadataId: string,
): Promise<FileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
@@ -39,7 +45,7 @@ export class FilesFieldResolver {
filename,
declaredMimeType: mimetype,
workspaceId,
applicationId: workspaceCustomApplicationId,
fieldMetadataId,
});
}
}
@@ -243,6 +243,7 @@ describe('WorkspaceEntityManager', () => {
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
IS_FILES_FIELD_MIGRATED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -1223,7 +1223,6 @@ export class WorkspaceEntityManager extends EntityManager {
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
filesFieldDiffByEntityIndex =
filesFieldSync.computeFilesFieldDiffBeforeUpsert(
@@ -1241,7 +1240,6 @@ export class WorkspaceEntityManager extends EntityManager {
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
entityWithConnectedRelations.splice(
0,
@@ -1286,10 +1284,7 @@ export class WorkspaceEntityManager extends EntityManager {
.finally(() => queryRunnerForEntityPersistExecutor.release());
if (isDefined(filesFieldFileIds)) {
await filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
await filesFieldSync.updateFileEntityRecords(filesFieldFileIds);
}
const resultArray = Array.isArray(result) ? result : [result];
@@ -277,6 +277,28 @@ export class FilesFieldSync {
}
}
private validateFileFieldUniversalIdentifier(
fileId: string,
fileEntity: FileEntity,
fileIdToFieldUniversalIdentifier: Map<string, string>,
): void {
const expectedUniversalIdentifier =
fileIdToFieldUniversalIdentifier.get(fileId);
if (
isDefined(expectedUniversalIdentifier) &&
!fileEntity.path.includes(expectedUniversalIdentifier)
) {
throw new TwentyORMException(
`File ${fileId} was not uploaded for this field`,
TwentyORMExceptionCode.INVALID_INPUT,
{
userFriendlyMessage: msg`File ${fileId} was not uploaded for this field. Please re-upload the file.`,
},
);
}
}
private validateAndComputeFilesFieldDiff(
entity: Record<string, unknown>,
filesField: FlatFieldMetadata,
@@ -358,7 +380,6 @@ export class FilesFieldSync {
toUpdate: Set<string>;
toRemove: Set<string>;
};
fileIdToApplicationId: Map<string, string>;
}> {
if (Object.keys(filesFieldDiffByEntityIndex).length === 0) {
return {
@@ -368,7 +389,6 @@ export class FilesFieldSync {
toUpdate: new Set<string>(),
toRemove: new Set<string>(),
},
fileIdToApplicationId: new Map(),
};
}
@@ -377,12 +397,11 @@ export class FilesFieldSync {
this.internalContext,
);
const { toAdd, toUpdate, toRemove, fileIdToApplicationId } =
await this.validateAndEnrichFileDiffs(
filesFieldDiffByEntityIndex,
workspaceId,
objectMetadata.id,
);
const { toAdd, toUpdate, toRemove } = await this.validateAndEnrichFileDiffs(
filesFieldDiffByEntityIndex,
workspaceId,
objectMetadata.id,
);
const updatedEntities = this.updateEntitiesWithEnrichedFilesFieldValues(
entities,
@@ -392,7 +411,6 @@ export class FilesFieldSync {
return {
entities: updatedEntities,
fileIds: { toAdd, toUpdate, toRemove },
fileIdToApplicationId,
};
}
@@ -404,7 +422,6 @@ export class FilesFieldSync {
toAdd: Set<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
fileIdToApplicationId: Map<string, string>;
}> {
const allFileIds = {
toAdd: new Set<string>(),
@@ -412,28 +429,39 @@ export class FilesFieldSync {
toRemove: new Set<string>(),
};
const fileIdToApplicationId = new Map<string, string>();
const allFileIdsToFetch = new Set<string>();
const filesFields = this.getFilesFields(objectMetadataId);
const fieldNameToApplicationId = new Map(
filesFields.map((field) => [field.name, field.applicationId]),
const fieldNameToUniversalIdentifier = new Map(
filesFields.map((field) => [field.name, field.universalIdentifier]),
);
const fileIdToFieldUniversalIdentifier = new Map<string, string>();
for (const entityDiffs of Object.values(filesFieldDiffByEntityIndex)) {
for (const [fieldName, diff] of Object.entries(entityDiffs)) {
const fieldApplicationId = fieldNameToApplicationId.get(fieldName);
const fieldUniversalIdentifier =
fieldNameToUniversalIdentifier.get(fieldName);
diff.toAdd.forEach((file) => {
allFileIds.toAdd.add(file.fileId);
allFileIdsToFetch.add(file.fileId);
if (fieldApplicationId) {
fileIdToApplicationId.set(file.fileId, fieldApplicationId);
if (isDefined(fieldUniversalIdentifier)) {
fileIdToFieldUniversalIdentifier.set(
file.fileId,
fieldUniversalIdentifier,
);
}
});
diff.toUpdate.forEach((file) => {
allFileIds.toUpdate.add(file.fileId);
allFileIdsToFetch.add(file.fileId);
if (isDefined(fieldUniversalIdentifier)) {
fileIdToFieldUniversalIdentifier.set(
file.fileId,
fieldUniversalIdentifier,
);
}
});
diff.toRemove.forEach((file) => {
allFileIds.toRemove.add(file.fileId);
@@ -442,7 +470,7 @@ export class FilesFieldSync {
}
if (allFileIdsToFetch.size === 0 && allFileIds.toRemove.size === 0) {
return { ...allFileIds, fileIdToApplicationId };
return allFileIds;
}
const existingFiles = await this.fileRepository.find({
@@ -469,6 +497,12 @@ export class FilesFieldSync {
);
}
this.validateFileFieldUniversalIdentifier(
file.fileId,
fileEntity,
fileIdToFieldUniversalIdentifier,
);
if (!fileEntity.settings?.isTemporaryFile) {
const fileId = file.fileId;
@@ -494,6 +528,12 @@ export class FilesFieldSync {
);
}
this.validateFileFieldUniversalIdentifier(
file.fileId,
fileEntity,
fileIdToFieldUniversalIdentifier,
);
if (fileEntity.settings?.isTemporaryFile) {
throw new TwentyORMException(
`File ${file.fileId} to update should not be a temporary file`,
@@ -507,50 +547,24 @@ export class FilesFieldSync {
}
}
return { ...allFileIds, fileIdToApplicationId };
return allFileIds;
}
async updateFileEntityRecords(
fileIds: {
toAdd: Set<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
},
fileIdToApplicationId: Map<string, string>,
): Promise<void> {
async updateFileEntityRecords(fileIds: {
toAdd: Set<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
}): Promise<void> {
if (fileIds.toAdd.size > 0) {
const fileIdsByApplicationId = Array.from(fileIds.toAdd).reduce(
(acc, fileId) => {
const applicationId = fileIdToApplicationId.get(fileId);
if (!applicationId) {
throw new TwentyORMException(
`Application ID not found for file ${fileId}`,
TwentyORMExceptionCode.INVALID_INPUT,
);
}
acc[applicationId] = [...(acc[applicationId] || []), fileId];
return acc;
},
{} as Record<string, string[]>,
);
for (const [applicationId, fileIds] of Object.entries(
fileIdsByApplicationId,
)) {
await this.fileRepository.update(
{ id: In(fileIds) },
{
settings: {
isTemporaryFile: false,
toDelete: false,
},
applicationId,
await this.fileRepository.update(
{ id: In([...fileIds.toAdd]) },
{
settings: {
isTemporaryFile: false,
toDelete: false,
},
);
}
},
);
}
if (fileIds.toRemove.size > 0) {
@@ -169,7 +169,6 @@ export class WorkspaceInsertQueryBuilder<
);
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
const entities = Array.isArray(this.expressionMap.valuesSet)
? this.expressionMap.valuesSet
@@ -191,7 +190,6 @@ export class WorkspaceInsertQueryBuilder<
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
this.expressionMap.valuesSet = Array.isArray(
this.expressionMap.valuesSet,
@@ -227,10 +225,7 @@ export class WorkspaceInsertQueryBuilder<
const result = await super.execute();
if (isDefined(filesFieldFileIds)) {
await this.filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
await this.filesFieldSync.updateFileEntityRecords(filesFieldFileIds);
}
const eventSelectQueryBuilder = (
this.connection.manager as WorkspaceEntityManager
@@ -173,7 +173,6 @@ export class WorkspaceUpdateQueryBuilder<
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
const updatePayload = Array.isArray(this.expressionMap.valuesSet)
? (this.expressionMap.valuesSet[0] ?? {})
@@ -197,7 +196,6 @@ export class WorkspaceUpdateQueryBuilder<
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
this.expressionMap.valuesSet = result.entities[0];
}
@@ -236,10 +234,7 @@ export class WorkspaceUpdateQueryBuilder<
const result = await super.execute();
if (isDefined(filesFieldFileIds)) {
await this.filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
await this.filesFieldSync.updateFileEntityRecords(filesFieldFileIds);
}
const after = await eventSelectQueryBuilder.getMany();
@@ -374,7 +369,6 @@ export class WorkspaceUpdateQueryBuilder<
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = null;
const entities = this.manyInputs.map((input) => input.partialEntity);
@@ -394,7 +388,6 @@ export class WorkspaceUpdateQueryBuilder<
});
filesFieldFileIds = result.fileIds;
fileIdToApplicationId = result.fileIdToApplicationId;
this.manyInputs = result.entities.map((updatedEntity, index) => ({
criteria: this.manyInputs[index].criteria,
@@ -451,11 +444,8 @@ export class WorkspaceUpdateQueryBuilder<
results.push(result);
}
if (isDefined(filesFieldFileIds) && isDefined(fileIdToApplicationId)) {
await this.filesFieldSync.updateFileEntityRecords(
filesFieldFileIds,
fileIdToApplicationId,
);
if (isDefined(filesFieldFileIds)) {
await this.filesFieldSync.updateFileEntityRecords(filesFieldFileIds);
}
const afterRecords = await eventSelectQueryBuilder.getMany();
@@ -1,10 +1,10 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
DateDisplayFormat,
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
} from 'twenty-shared/types';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type';
@@ -126,6 +126,27 @@ export const buildAttachmentStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
file: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'file',
type: FieldMetadataType.FILES,
label: 'File',
description: 'Attachment file',
icon: 'IconFileUpload',
isNullable: true,
isUIReadOnly: true,
settings: {
maxNumberOfValues: 1,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
//deprecated
fullPath: createStandardFieldFlatMetadata({
objectName,
workspaceId,
@@ -143,6 +164,7 @@ export const buildAttachmentStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
//deprecated
fileCategory: createStandardFieldFlatMetadata({
objectName,
workspaceId,
@@ -33,6 +33,7 @@ export const ATTACHMENT_STANDARD_FIELD_IDS = {
name: '20202020-87a5-48f8-bbf7-ade388825a57',
fullPath: '20202020-0d19-453d-8e8d-fbcda8ca3747',
type: '20202020-a417-49b8-a40b-f6a7874caa0d',
file: '20202020-15db-460e-8166-c7b5d87ad4be',
fileCategory: '20202020-8c3f-4d9e-9a1b-2e5f7a8c9d0e',
createdBy: '395be3bd-a5c9-463d-aafe-9bc3bbec3f15',
updatedBy: '376239d1-3e65-4cb6-b5d8-e0917d43cc93',