Fix blocknote.map crash with generic field-level RICH_TEXT_V2 handler (#17834)

## Summary

Fixes #17667

- **Root cause**: `ActivityQueryResultGetterHandler` called
`JSON.parse()` on the `blocknote` field and assumed the result was
always an array. When the stored value was valid JSON but not an array
(e.g., `"{}"`), `blocknote.map()` crashed with `blocknote.map is not a
function`, breaking the entire notes page.
- **Fix**: Replaced the object-level `ActivityQueryResultGetterHandler`
(hardcoded for `note`/`task` only) with a generic field-level
`RichTextV2FieldQueryResultGetterHandler` that safely parses blocknote
JSON with `Array.isArray` validation and gracefully skips malformed
values instead of crashing.
- **Bonus**: The new handler works for **all** objects with
`RICH_TEXT_V2` fields (not just `note`/`task`), following the same
pattern as the existing `FilesFieldQueryResultGetterHandler`.

## Changes

| File | Change |
|------|--------|
| `rich-text-v2-field-query-result-getter.handler.ts` | New field-level
handler with safe blocknote parsing |
| `common-result-getters.service.ts` | Register new handler, remove
`note`/`task` object handlers |
| `activity-query-result-getter.handler.ts` | Deleted (replaced by
field-level handler) |
| `rich-text-v2-field-query-result-getter.handler.spec.ts` | 9 tests
covering all edge cases |

## Test plan

- [x] Unit tests pass (9 tests covering: null blocknote, non-string
blocknote, invalid JSON, non-array JSON like `"{}"`, no images, external
URLs, internal URLs, multiple fields)
- [x] Lint passes (`lint:diff-with-main`)
- [x] Typecheck passes


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-10 18:07:49 +01:00
committed by GitHub
parent 17786a3298
commit bb037e13dd
5 changed files with 349 additions and 277 deletions
@@ -11,7 +11,7 @@ import { type QueryResultFieldValue } from 'src/engine/api/graphql/workspace-que
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { FilesFieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler';
import { ActivityQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler';
import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler';
import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler';
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
@@ -55,8 +55,6 @@ export class CommonResultGettersService {
'workspaceMember',
new WorkspaceMemberQueryResultGetterHandler(this.fileService),
],
['note', new ActivityQueryResultGetterHandler(this.fileService)],
['task', new ActivityQueryResultGetterHandler(this.fileService)],
]);
}
@@ -69,6 +67,10 @@ export class CommonResultGettersService {
FieldMetadataType.FILES,
new FilesFieldQueryResultGetterHandler(this.filesFieldService),
],
[
FieldMetadataType.RICH_TEXT_V2,
new RichTextV2FieldQueryResultGetterHandler(this.fileService),
],
]);
}
@@ -0,0 +1,224 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
const baseRecord: ObjectRecord = {
id: '1',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
deletedAt: null,
};
const richTextFieldMetadata = [
{
type: FieldMetadataType.RICH_TEXT_V2,
name: 'bodyV2',
},
] as FlatFieldMetadata[];
const mockFileService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileService;
describe('RichTextV2FieldQueryResultGetterHandler', () => {
let handler: RichTextV2FieldQueryResultGetterHandler;
beforeEach(() => {
process.env.SERVER_URL = 'https://my-domain.twenty.com';
handler = new RichTextV2FieldQueryResultGetterHandler(mockFileService);
});
afterEach(() => {
jest.clearAllMocks();
delete process.env.SERVER_URL;
});
describe('should return record unchanged', () => {
it('when no RICH_TEXT_V2 field metadata is present', async () => {
const record = {
...baseRecord,
bodyV2: { blocknote: '[]', markdown: null },
};
const result = await handler.handle(record, 'ws-1', []);
expect(result).toEqual(record);
});
it('when blocknote is null', async () => {
const record = {
...baseRecord,
bodyV2: { blocknote: null, markdown: null },
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual(record);
});
it('when blocknote is not a string', async () => {
const record = {
...baseRecord,
bodyV2: { blocknote: 123, markdown: null },
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual(record);
});
it('when blocknote is an invalid JSON string', async () => {
const record = {
...baseRecord,
bodyV2: { blocknote: 'not-json', markdown: null },
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual(record);
});
it('when blocknote parses to a non-array value', async () => {
const record = {
...baseRecord,
bodyV2: { blocknote: '{}', markdown: null },
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual(record);
});
it('when blocknote has no image blocks', async () => {
const record = {
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([
{ type: 'paragraph', text: 'Hello, world!' },
]),
},
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual(record);
});
it('when image block has external URL', async () => {
const record = {
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([
{
type: 'image',
props: { url: 'https://external.com/image.jpg' },
},
]),
},
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual(record);
});
});
describe('should sign internal image URLs', () => {
it('when image block has an internal attachment URL', async () => {
const imageBlock = {
type: 'image',
props: {
name: 'photo.jpg',
url: 'https://my-domain.twenty.com/files/attachment/some-token/photo.jpg',
caption: '',
},
children: [],
};
const record = {
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([imageBlock]),
},
};
const result = await handler.handle(
record,
'ws-1',
richTextFieldMetadata,
);
expect(result).toEqual({
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([
{
...imageBlock,
props: {
...imageBlock.props,
url: 'https://my-domain.twenty.com/files/signed-path',
},
},
]),
},
});
});
});
describe('should handle multiple RICH_TEXT_V2 fields', () => {
it('when record has multiple rich text fields', async () => {
const multiFieldMetadata = [
{ type: FieldMetadataType.RICH_TEXT_V2, name: 'bodyV2' },
{ type: FieldMetadataType.RICH_TEXT_V2, name: 'description' },
] as FlatFieldMetadata[];
const record = {
...baseRecord,
bodyV2: {
markdown: null,
blocknote: JSON.stringify([{ type: 'paragraph', text: 'Hello' }]),
},
description: {
markdown: null,
blocknote: '{}',
},
};
const result = await handler.handle(record, 'ws-1', multiFieldMetadata);
// bodyV2 should be unchanged (no images), description should be
// unchanged (non-array blocknote)
expect(result).toEqual(record);
});
});
});
@@ -0,0 +1,120 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type RichTextBlock = Record<string, any>;
const signBlocknoteImageUrls = (
blocknoteBlocks: RichTextBlock[],
workspaceId: string,
fileService: FileService,
): RichTextBlock[] => {
return blocknoteBlocks.map((block: RichTextBlock) => {
if (block.type !== 'image' || !block.props?.url) {
return block;
}
let url: URL;
try {
url = new URL(block.props.url);
} catch {
return block;
}
const pathname = url.pathname;
const isLinkExternal = !pathname.startsWith('/files/attachment/');
if (isLinkExternal) {
return block;
}
const fileName = pathname.match(/files\/attachment\/(?:.+)\/(.+)$/)?.[1];
if (!isDefined(fileName)) {
return block;
}
const signedPath = fileService.signFileUrl({
url: `attachment/${fileName}`,
workspaceId,
});
return {
...block,
props: {
...block.props,
url: `${process.env.SERVER_URL}/files/${signedPath}`,
},
};
});
};
const parseBlocknoteJsonSafely = (
blocknoteJson: string,
): RichTextBlock[] | null => {
try {
const parsed = JSON.parse(blocknoteJson);
if (!Array.isArray(parsed)) {
return null;
}
return parsed;
} catch {
return null;
}
};
export class RichTextV2FieldQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(private readonly fileService: FileService) {}
async handle(
record: ObjectRecord,
workspaceId: string,
flatFieldMetadata: FlatFieldMetadata[],
): Promise<ObjectRecord> {
const richTextV2Fields = flatFieldMetadata.filter(
(field) => field.type === FieldMetadataType.RICH_TEXT_V2,
);
if (richTextV2Fields.length === 0) {
return record;
}
for (const field of richTextV2Fields) {
const fieldValue = record[field.name];
const blocknoteJson = fieldValue?.blocknote;
if (!blocknoteJson || typeof blocknoteJson !== 'string') {
continue;
}
const blocknoteBlocks = parseBlocknoteJsonSafely(blocknoteJson);
if (!isDefined(blocknoteBlocks)) {
continue;
}
const signedBlocks = signBlocknoteImageUrls(
blocknoteBlocks,
workspaceId,
this.fileService,
);
record[field.name] = {
...fieldValue,
blocknote: JSON.stringify(signedBlocks),
};
}
return record;
}
}