Files
twenty/packages/twenty-server/test/integration/graphql/suites/object-generated/tasks-post-hook.integration-spec.ts
T
Álvaro Fernández e4f8d804d5 fix(16819): Add NoteDeleteOnePostQueryHook for soft removing note targets (#16826)
Fixes [#16819](https://github.com/twentyhq/twenty/issues/16819)

Added a post-query hook (NoteDeleteOnePostQueryHook) that automatically
soft-deletes all associated noteTarget records when a note is deleted.

## Key changes:

Created `note-delete-one.post-query.hook.ts`
Uses `GlobalWorkspaceOrmManager` to access workspace entities correctly
Executes in the proper workspace context to ensure data isolation
Soft-deletes noteTarget records to maintain referential consistency

## Testing
1. Company notes
Create a Company
Create and attach a Note to the Company
Navigate to Company → Notes page (should display the note)
Delete the Note
Navigate to Company → Notes page (should no longer crash)
Restore the Note (should appear again)
Delete and destroy the Note permanently
Navigate to Company → Notes page (should still work, just empty)

2. Opportunity notes
Create an Opportunity
Create and attach a Note to the Opportunity
Navigate to Opportunity → Notes page (should display the note)
Delete the Note
Navigate to Opportunity → Notes page (should no longer crash)
Restore the Note (should appear again)
Delete and destroy the Note permanently
Navigate to Opportunity → Notes page (should still work, just empty)

### Expected behavior:

Company Notes page remains functional at all stages
No console errors
Deleted notes properly excluded from the view
Other notes on the same Company remain accessible
Additional Notes
This fix ensures data consistency by maintaining the relationship
between notes and their targets throughout the deletion lifecycle.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-01-07 08:30:08 +00:00

325 lines
11 KiB
TypeScript

import { randomUUID } from 'crypto';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util';
import { restoreOneOperationFactory } from 'test/integration/graphql/utils/restore-one-operation-factory.util';
const TASK_GQL_FIELDS = `
id
title
deletedAt
`;
const TASK_TARGET_GQL_FIELDS = `
id
taskId
deletedAt
`;
describe('Task post-query hooks', () => {
const taskIds: string[] = [];
const taskTargetIds: string[] = [];
afterAll(async () => {
if (taskIds.length > 0) {
const destroyTasksOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: 'id',
filter: { id: { in: taskIds } },
});
await makeGraphqlAPIRequest(destroyTasksOperation);
}
if (taskTargetIds.length > 0) {
const destroyTaskTargetsOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: 'id',
filter: { id: { in: taskTargetIds } },
});
await makeGraphqlAPIRequest(destroyTaskTargetsOperation);
}
});
it('deleteOne should soft delete related taskTargets', async () => {
const taskId = randomUUID();
const taskTargetId = randomUUID();
taskIds.push(taskId);
taskTargetIds.push(taskTargetId);
const createTaskOperation = createOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
data: { id: taskId, title: 'Test Task for DeleteOne' },
});
await makeGraphqlAPIRequest(createTaskOperation);
const createTaskTargetOperation = createOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
data: { id: taskTargetId, taskId },
});
await makeGraphqlAPIRequest(createTaskTargetOperation);
const deleteTaskOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
recordId: taskId,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteTaskOperation);
expect(deleteResponse.body.data.deleteTask).toBeDefined();
expect(deleteResponse.body.data.deleteTask.deletedAt).not.toBeNull();
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: {
id: { eq: taskTargetId },
not: { deletedAt: { is: 'NULL' } },
},
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(1);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).not.toBeNull();
});
it('deleteMany should soft delete related taskTargets', async () => {
const taskId1 = randomUUID();
const taskId2 = randomUUID();
const taskTargetId1 = randomUUID();
const taskTargetId2 = randomUUID();
taskIds.push(taskId1, taskId2);
taskTargetIds.push(taskTargetId1, taskTargetId2);
const createTask1Operation = createOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
data: { id: taskId1, title: 'Test Task 1 for DeleteMany' },
});
const createTask2Operation = createOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
data: { id: taskId2, title: 'Test Task 2 for DeleteMany' },
});
await Promise.all([
makeGraphqlAPIRequest(createTask1Operation),
makeGraphqlAPIRequest(createTask2Operation),
]);
const createTaskTarget1Operation = createOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
data: { id: taskTargetId1, taskId: taskId1 },
});
const createTaskTarget2Operation = createOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
data: { id: taskTargetId2, taskId: taskId2 },
});
await Promise.all([
makeGraphqlAPIRequest(createTaskTarget1Operation),
makeGraphqlAPIRequest(createTaskTarget2Operation),
]);
const deleteTasksOperation = deleteManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: TASK_GQL_FIELDS,
filter: { id: { in: [taskId1, taskId2] } },
});
const deleteResponse = await makeGraphqlAPIRequest(deleteTasksOperation);
expect(deleteResponse.body.data.deleteTasks).toHaveLength(2);
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: {
id: { in: [taskTargetId1, taskTargetId2] },
not: { deletedAt: { is: 'NULL' } },
},
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(2);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).not.toBeNull();
expect(
taskTargetResponse.body.data.taskTargets.edges[1].node.deletedAt,
).not.toBeNull();
});
it('restoreOne should restore related taskTargets', async () => {
const taskId = randomUUID();
const taskTargetId = randomUUID();
taskIds.push(taskId);
taskTargetIds.push(taskTargetId);
const createTaskOperation = createOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
data: { id: taskId, title: 'Test Task for RestoreOne' },
});
await makeGraphqlAPIRequest(createTaskOperation);
const createTaskTargetOperation = createOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
data: { id: taskTargetId, taskId },
});
await makeGraphqlAPIRequest(createTaskTargetOperation);
const deleteTaskOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
recordId: taskId,
});
await makeGraphqlAPIRequest(deleteTaskOperation);
const restoreTaskOperation = restoreOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
recordId: taskId,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreTaskOperation);
expect(restoreResponse.body.data.restoreTask).toBeDefined();
expect(restoreResponse.body.data.restoreTask.deletedAt).toBeNull();
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: { id: { eq: taskTargetId } },
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(1);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).toBeNull();
});
it('restoreMany should restore related taskTargets', async () => {
const taskId1 = randomUUID();
const taskId2 = randomUUID();
const taskTargetId1 = randomUUID();
const taskTargetId2 = randomUUID();
taskIds.push(taskId1, taskId2);
taskTargetIds.push(taskTargetId1, taskTargetId2);
const createTask1Operation = createOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
data: { id: taskId1, title: 'Test Task 1 for RestoreMany' },
});
const createTask2Operation = createOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
data: { id: taskId2, title: 'Test Task 2 for RestoreMany' },
});
await Promise.all([
makeGraphqlAPIRequest(createTask1Operation),
makeGraphqlAPIRequest(createTask2Operation),
]);
const createTaskTarget1Operation = createOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
data: { id: taskTargetId1, taskId: taskId1 },
});
const createTaskTarget2Operation = createOneOperationFactory({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
data: { id: taskTargetId2, taskId: taskId2 },
});
await Promise.all([
makeGraphqlAPIRequest(createTaskTarget1Operation),
makeGraphqlAPIRequest(createTaskTarget2Operation),
]);
const deleteTasksOperation = deleteManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: TASK_GQL_FIELDS,
filter: { id: { in: [taskId1, taskId2] } },
});
await makeGraphqlAPIRequest(deleteTasksOperation);
const restoreTasksOperation = restoreManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: TASK_GQL_FIELDS,
filter: { id: { in: [taskId1, taskId2] } },
});
const restoreResponse = await makeGraphqlAPIRequest(restoreTasksOperation);
expect(restoreResponse.body.data.restoreTasks).toHaveLength(2);
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: { id: { in: [taskTargetId1, taskTargetId2] } },
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(2);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).toBeNull();
expect(
taskTargetResponse.body.data.taskTargets.edges[1].node.deletedAt,
).toBeNull();
});
});