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:
-185
@@ -1,185 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { FieldActorSource } from 'twenty-shared/types';
|
||||
|
||||
import { ActivityQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { type NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
|
||||
|
||||
const baseNote = {
|
||||
id: '1',
|
||||
bodyV2: {
|
||||
markdown: null,
|
||||
blocknote: null,
|
||||
},
|
||||
position: 1,
|
||||
title: 'Test',
|
||||
createdBy: {
|
||||
name: 'Test',
|
||||
source: FieldActorSource.MANUAL,
|
||||
workspaceMemberId: '1',
|
||||
context: {},
|
||||
},
|
||||
updatedBy: {
|
||||
name: 'Test',
|
||||
source: FieldActorSource.MANUAL,
|
||||
workspaceMemberId: '1',
|
||||
context: {},
|
||||
},
|
||||
createdAt: '2021-01-01',
|
||||
updatedAt: '2021-01-01',
|
||||
noteTargets: [],
|
||||
attachments: [],
|
||||
timelineActivities: [],
|
||||
favorites: [],
|
||||
searchVector: '',
|
||||
deletedAt: null,
|
||||
} satisfies NoteWorkspaceEntity;
|
||||
|
||||
const baseTask = {
|
||||
...baseNote,
|
||||
type: 'task',
|
||||
};
|
||||
|
||||
describe('ActivityQueryResultGetterHandler', () => {
|
||||
let handler: ActivityQueryResultGetterHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.SERVER_URL = 'https://my-domain.twenty.com';
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ActivityQueryResultGetterHandler,
|
||||
{
|
||||
provide: FileService,
|
||||
useValue: {
|
||||
signFileUrl: jest.fn().mockReturnValue('signed-path'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
handler = module.get<ActivityQueryResultGetterHandler>(
|
||||
ActivityQueryResultGetterHandler,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.SERVER_URL;
|
||||
});
|
||||
|
||||
describe('should do nothing', () => {
|
||||
it('when activity is a note and no image is found', async () => {
|
||||
const note = {
|
||||
...baseNote,
|
||||
bodyV2: {
|
||||
markdown: null,
|
||||
blocknote: JSON.stringify([
|
||||
{ type: 'paragraph', text: 'Hello, world!' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handler.handle(note, '1');
|
||||
|
||||
expect(result).toEqual(note);
|
||||
});
|
||||
|
||||
it('when activity is a note and link is external', async () => {
|
||||
const note = {
|
||||
...baseNote,
|
||||
bodyV2: {
|
||||
markdown: null,
|
||||
blocknote: JSON.stringify([
|
||||
{
|
||||
id: 'c6a5f700-5e56-480d-90a9-7f295216370e',
|
||||
type: 'image',
|
||||
props: {
|
||||
backgroundColor: 'default',
|
||||
textAlignment: 'left',
|
||||
name: '20240529_123208.jpg',
|
||||
url: 'http://external-content.com/image.jpg',
|
||||
caption: '',
|
||||
showPreview: true,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'e2454736-51c1-4e61-a02d-71f0890bdda7',
|
||||
type: 'paragraph',
|
||||
props: {
|
||||
textColor: 'default',
|
||||
backgroundColor: 'default',
|
||||
textAlignment: 'left',
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handler.handle(note, '1');
|
||||
|
||||
expect(result).toEqual(note);
|
||||
});
|
||||
|
||||
it('when activity is a task and no image is found', async () => {
|
||||
const task = {
|
||||
...baseTask,
|
||||
bodyV2: {
|
||||
markdown: null,
|
||||
blocknote: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handler.handle(task, '1');
|
||||
|
||||
expect(result).toEqual(task);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should update token in file link', () => {
|
||||
it('when file link is in the body', async () => {
|
||||
const imageBlock = {
|
||||
id: 'c6a5f700-5e56-480d-90a9-7f295216370e',
|
||||
type: 'image',
|
||||
props: {
|
||||
backgroundColor: 'default',
|
||||
textAlignment: 'left',
|
||||
name: '20240529_123208.jpg',
|
||||
url: 'https://my-domain.twenty.com/files/attachment/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlbmFtZSI6ImU0NWNiNDhhLTM2MmYtNGU4Zi1iOTEzLWM5MmI1ZTNlMGFhNi5qcGciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsInN1YiI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsInR5cGUiOiJGSUxFIiwiaWF0IjoxNzUwNDI4NDQ1LCJleHAiOjE3NTA1MTQ4NDV9.qTN1b9IcmZvfVAqt1UlfJ_nn3GwIAEp7G9IoPtRJDxk/e45cb48a-362f-4e8f-b913-c92b5e3e0aa6.jpg',
|
||||
caption: '',
|
||||
showPreview: true,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
|
||||
const note = {
|
||||
...baseNote,
|
||||
bodyV2: {
|
||||
markdown: null,
|
||||
blocknote: JSON.stringify([imageBlock]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handler.handle(note, '1');
|
||||
|
||||
expect(result).toEqual({
|
||||
...note,
|
||||
bodyV2: {
|
||||
markdown: null,
|
||||
blocknote: JSON.stringify([
|
||||
{
|
||||
...imageBlock,
|
||||
props: {
|
||||
...imageBlock.props,
|
||||
url: 'https://my-domain.twenty.com/files/signed-path',
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
|
||||
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { type NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
|
||||
import { type TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type RichTextBlock = Record<string, any>;
|
||||
|
||||
type RichTextBody = RichTextBlock[];
|
||||
|
||||
@Injectable()
|
||||
export class ActivityQueryResultGetterHandler
|
||||
implements QueryResultGetterHandlerInterface
|
||||
{
|
||||
constructor(private readonly fileService: FileService) {}
|
||||
|
||||
async handle(
|
||||
activity: TaskWorkspaceEntity | NoteWorkspaceEntity,
|
||||
workspaceId: string,
|
||||
): Promise<TaskWorkspaceEntity | NoteWorkspaceEntity> {
|
||||
const blocknoteJson = activity.bodyV2?.blocknote;
|
||||
|
||||
if (!activity.id || !blocknoteJson) {
|
||||
return activity;
|
||||
}
|
||||
|
||||
let blocknote: RichTextBody = [];
|
||||
|
||||
try {
|
||||
blocknote = JSON.parse(blocknoteJson);
|
||||
} catch {
|
||||
blocknote = [];
|
||||
// TODO: Remove this once we have removed the old rich text
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Failed to parse body for activity ${activity.id} in workspace ${workspaceId}, for rich text version 'v2'`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(blocknoteJson);
|
||||
}
|
||||
|
||||
const blocknoteWithSignedPayload = await Promise.all(
|
||||
blocknote.map(async (block: RichTextBlock) => {
|
||||
if (block.type !== 'image' || !block.props.url) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const imageProps = block.props;
|
||||
const url = new URL(imageProps.url);
|
||||
|
||||
const pathname = url.pathname;
|
||||
|
||||
const isLinkExternal = !pathname.startsWith('/files/attachment/');
|
||||
|
||||
if (isLinkExternal) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const fileName = pathname.match(
|
||||
/files\/attachment\/(?:.+)\/(.+)$/,
|
||||
)?.[1];
|
||||
|
||||
const signedPath = this.fileService.signFileUrl({
|
||||
url: `attachment/${fileName}`,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
...block,
|
||||
props: {
|
||||
...imageProps,
|
||||
url: `${process.env.SERVER_URL}/files/${signedPath}`,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...activity,
|
||||
bodyV2: {
|
||||
blocknote: JSON.stringify(blocknoteWithSignedPayload),
|
||||
markdown: activity.bodyV2?.markdown ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user