mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(API): Add cancel status filters to the public api executions endpoint (#19136)
This commit is contained in:
@@ -32,7 +32,7 @@ export class ExecutionEntity {
|
|||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the execution finished sucessfully.
|
* Whether the execution finished successfully.
|
||||||
*
|
*
|
||||||
* @deprecated Use `status` instead
|
* @deprecated Use `status` instead
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Container, type Constructable } from '@n8n/di';
|
||||||
|
import { DataSource, EntityManager, In, LessThan, type EntityMetadata } from '@n8n/typeorm';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { Class } from 'n8n-core';
|
||||||
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
|
|
||||||
|
import { ExecutionEntity } from '../../entities';
|
||||||
|
import { ExecutionRepository } from '../execution.repository';
|
||||||
|
|
||||||
|
const mockInstance = <T>(
|
||||||
|
serviceClass: Constructable<T>,
|
||||||
|
data: DeepPartial<T> | undefined = undefined,
|
||||||
|
) => {
|
||||||
|
const instance = mock<T>(data);
|
||||||
|
Container.set(serviceClass, instance);
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEntityManager = (entityClass: Class) => {
|
||||||
|
const entityManager = mockInstance(EntityManager);
|
||||||
|
const dataSource = mockInstance(DataSource, {
|
||||||
|
manager: entityManager,
|
||||||
|
getMetadata: () => mock<EntityMetadata>({ target: entityClass }),
|
||||||
|
});
|
||||||
|
Object.assign(entityManager, { connection: dataSource });
|
||||||
|
return entityManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExecutionRepository', () => {
|
||||||
|
const entityManager = mockEntityManager(ExecutionEntity);
|
||||||
|
const executionRepository = Container.get(ExecutionRepository);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExecutionsForPublicApi', () => {
|
||||||
|
test('should get executions matching the filter parameters', async () => {
|
||||||
|
const limit = 10;
|
||||||
|
const params = {
|
||||||
|
limit: 10,
|
||||||
|
lastId: '3',
|
||||||
|
workflowIds: ['3', '4'],
|
||||||
|
};
|
||||||
|
const mockEntities = [{ id: '1' }, { id: '2' }];
|
||||||
|
|
||||||
|
entityManager.find.mockResolvedValueOnce(mockEntities);
|
||||||
|
const result = await executionRepository.getExecutionsForPublicApi(params);
|
||||||
|
|
||||||
|
expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
|
||||||
|
select: [
|
||||||
|
'id',
|
||||||
|
'mode',
|
||||||
|
'retryOf',
|
||||||
|
'retrySuccessId',
|
||||||
|
'startedAt',
|
||||||
|
'stoppedAt',
|
||||||
|
'workflowId',
|
||||||
|
'waitTill',
|
||||||
|
'finished',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
where: {
|
||||||
|
id: LessThan(params.lastId),
|
||||||
|
workflowId: In(params.workflowIds),
|
||||||
|
},
|
||||||
|
order: { id: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
relations: ['executionData'],
|
||||||
|
});
|
||||||
|
expect(result.length).toBe(mockEntities.length);
|
||||||
|
expect(result[0].id).toEqual(mockEntities[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with status filter', () => {
|
||||||
|
test.each`
|
||||||
|
filterStatus | entityStatus
|
||||||
|
${'canceled'} | ${'canceled'}
|
||||||
|
${'error'} | ${In(['error', 'crashed'])}
|
||||||
|
${'success'} | ${'success'}
|
||||||
|
${'waiting'} | ${'waiting'}
|
||||||
|
`('should find all "$filterStatus" executions', async ({ filterStatus, entityStatus }) => {
|
||||||
|
const limit = 10;
|
||||||
|
const mockEntities = [{ id: '1' }, { id: '2' }];
|
||||||
|
|
||||||
|
entityManager.find.mockResolvedValueOnce(mockEntities);
|
||||||
|
const result = await executionRepository.getExecutionsForPublicApi({
|
||||||
|
limit,
|
||||||
|
status: filterStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
|
||||||
|
select: [
|
||||||
|
'id',
|
||||||
|
'mode',
|
||||||
|
'retryOf',
|
||||||
|
'retrySuccessId',
|
||||||
|
'startedAt',
|
||||||
|
'stoppedAt',
|
||||||
|
'workflowId',
|
||||||
|
'waitTill',
|
||||||
|
'finished',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
where: { status: entityStatus },
|
||||||
|
order: { id: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
relations: ['executionData'],
|
||||||
|
});
|
||||||
|
expect(result.length).toBe(mockEntities.length);
|
||||||
|
expect(result[0].id).toEqual(mockEntities[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each`
|
||||||
|
filterStatus
|
||||||
|
${'crashed'}
|
||||||
|
${'new'}
|
||||||
|
${'running'}
|
||||||
|
${'unknown'}
|
||||||
|
`(
|
||||||
|
'should find all executions and ignore status filter "$filterStatus"',
|
||||||
|
async ({ filterStatus }) => {
|
||||||
|
const limit = 10;
|
||||||
|
const mockEntities = [{ id: '1' }, { id: '2' }];
|
||||||
|
|
||||||
|
entityManager.find.mockResolvedValueOnce(mockEntities);
|
||||||
|
const result = await executionRepository.getExecutionsForPublicApi({
|
||||||
|
limit,
|
||||||
|
status: filterStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entityManager.find).toHaveBeenCalledWith(ExecutionEntity, {
|
||||||
|
select: [
|
||||||
|
'id',
|
||||||
|
'mode',
|
||||||
|
'retryOf',
|
||||||
|
'retrySuccessId',
|
||||||
|
'startedAt',
|
||||||
|
'stoppedAt',
|
||||||
|
'workflowId',
|
||||||
|
'waitTill',
|
||||||
|
'finished',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
where: {},
|
||||||
|
order: { id: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
relations: ['executionData'],
|
||||||
|
});
|
||||||
|
expect(result.length).toBe(mockEntities.length);
|
||||||
|
expect(result[0].id).toEqual(mockEntities[0].id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExecutionsCountForPublicApi', () => {
|
||||||
|
test('should get executions matching the filter parameters', async () => {
|
||||||
|
const limit = 10;
|
||||||
|
const mockCount = 20;
|
||||||
|
const params = {
|
||||||
|
limit: 10,
|
||||||
|
lastId: '3',
|
||||||
|
workflowIds: ['3', '4'],
|
||||||
|
};
|
||||||
|
|
||||||
|
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||||
|
const result = await executionRepository.getExecutionsCountForPublicApi(params);
|
||||||
|
|
||||||
|
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||||
|
where: {
|
||||||
|
id: LessThan(params.lastId),
|
||||||
|
workflowId: In(params.workflowIds),
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
expect(result).toBe(mockCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with status filter', () => {
|
||||||
|
test.each`
|
||||||
|
filterStatus | entityStatus
|
||||||
|
${'canceled'} | ${'canceled'}
|
||||||
|
${'error'} | ${In(['error', 'crashed'])}
|
||||||
|
${'success'} | ${'success'}
|
||||||
|
${'waiting'} | ${'waiting'}
|
||||||
|
`('should retrieve all $filterStatus executions', async ({ filterStatus, entityStatus }) => {
|
||||||
|
const limit = 10;
|
||||||
|
const mockCount = 20;
|
||||||
|
|
||||||
|
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||||
|
const result = await executionRepository.getExecutionsCountForPublicApi({
|
||||||
|
limit,
|
||||||
|
status: filterStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||||
|
where: { status: entityStatus },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(mockCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each`
|
||||||
|
filterStatus
|
||||||
|
${'crashed'}
|
||||||
|
${'new'}
|
||||||
|
${'running'}
|
||||||
|
${'unknown'}
|
||||||
|
`(
|
||||||
|
'should find all executions and ignore status filter "$filterStatus"',
|
||||||
|
async ({ filterStatus }) => {
|
||||||
|
const limit = 10;
|
||||||
|
const mockCount = 20;
|
||||||
|
|
||||||
|
entityManager.count.mockResolvedValueOnce(mockCount);
|
||||||
|
const result = await executionRepository.getExecutionsCountForPublicApi({
|
||||||
|
limit,
|
||||||
|
status: filterStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entityManager.count).toHaveBeenCalledWith(ExecutionEntity, {
|
||||||
|
where: {},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(mockCount);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -638,7 +638,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||||||
status?: ExecutionStatus;
|
status?: ExecutionStatus;
|
||||||
excludedWorkflowIds?: string[];
|
excludedWorkflowIds?: string[];
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const executions = await this.count({
|
const executionsCount = await this.count({
|
||||||
where: {
|
where: {
|
||||||
...(data.lastId && { id: LessThan(data.lastId) }),
|
...(data.lastId && { id: LessThan(data.lastId) }),
|
||||||
...(data.status && { ...this.getStatusCondition(data.status) }),
|
...(data.status && { ...this.getStatusCondition(data.status) }),
|
||||||
@@ -648,7 +648,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||||||
take: data.limit,
|
take: data.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return executions;
|
return executionsCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStatusCondition(status: ExecutionStatus) {
|
private getStatusCondition(status: ExecutionStatus) {
|
||||||
@@ -660,6 +660,8 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||||||
condition.status = 'waiting';
|
condition.status = 'waiting';
|
||||||
} else if (status === 'error') {
|
} else if (status === 'error') {
|
||||||
condition.status = In(['error', 'crashed']);
|
condition.status = In(['error', 'crashed']);
|
||||||
|
} else if (status === 'canceled') {
|
||||||
|
condition.status = 'canceled';
|
||||||
}
|
}
|
||||||
|
|
||||||
return condition;
|
return condition;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ get:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: ['error', 'success', 'waiting']
|
enum: ['canceled', 'error', 'success', 'waiting']
|
||||||
- name: workflowId
|
- name: workflowId
|
||||||
in: query
|
in: query
|
||||||
description: Workflow to filter the executions by.
|
description: Workflow to filter the executions by.
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
createTeamProject,
|
|
||||||
createManyWorkflows,
|
createManyWorkflows,
|
||||||
|
createTeamProject,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
|
mockInstance,
|
||||||
shareWorkflowWithUsers,
|
shareWorkflowWithUsers,
|
||||||
testDb,
|
testDb,
|
||||||
mockInstance,
|
|
||||||
} from '@n8n/backend-test-utils';
|
} from '@n8n/backend-test-utils';
|
||||||
import type { User, ExecutionEntity } from '@n8n/db';
|
import type { ExecutionEntity, User } from '@n8n/db';
|
||||||
|
import type { ExecutionStatus } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createdExecutionWithStatus,
|
||||||
createErrorExecution,
|
createErrorExecution,
|
||||||
createExecution,
|
createExecution,
|
||||||
createManyExecutions,
|
createManyExecutions,
|
||||||
createSuccessfulExecution,
|
createSuccessfulExecution,
|
||||||
createWaitingExecution,
|
|
||||||
} from '../shared/db/executions';
|
} from '../shared/db/executions';
|
||||||
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
|
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
@@ -238,46 +239,6 @@ describe('GET /executions', () => {
|
|||||||
|
|
||||||
test('should fail due to invalid API Key', testWithAPIKey('get', '/executions', 'abcXYZ'));
|
test('should fail due to invalid API Key', testWithAPIKey('get', '/executions', 'abcXYZ'));
|
||||||
|
|
||||||
test('should retrieve all successful executions', async () => {
|
|
||||||
const workflow = await createWorkflow({}, owner);
|
|
||||||
|
|
||||||
const successfulExecution = await createSuccessfulExecution(workflow);
|
|
||||||
|
|
||||||
await createErrorExecution(workflow);
|
|
||||||
|
|
||||||
const response = await authOwnerAgent.get('/executions').query({
|
|
||||||
status: 'success',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.body.data.length).toBe(1);
|
|
||||||
expect(response.body.nextCursor).toBe(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
finished,
|
|
||||||
mode,
|
|
||||||
retryOf,
|
|
||||||
retrySuccessId,
|
|
||||||
startedAt,
|
|
||||||
stoppedAt,
|
|
||||||
workflowId,
|
|
||||||
waitTill,
|
|
||||||
status,
|
|
||||||
} = response.body.data[0];
|
|
||||||
|
|
||||||
expect(id).toBeDefined();
|
|
||||||
expect(finished).toBe(true);
|
|
||||||
expect(mode).toEqual(successfulExecution.mode);
|
|
||||||
expect(retrySuccessId).toBeNull();
|
|
||||||
expect(retryOf).toBeNull();
|
|
||||||
expect(startedAt).not.toBeNull();
|
|
||||||
expect(stoppedAt).not.toBeNull();
|
|
||||||
expect(workflowId).toBe(successfulExecution.workflowId);
|
|
||||||
expect(waitTill).toBeNull();
|
|
||||||
expect(status).toBe(successfulExecution.status);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should paginate two executions', async () => {
|
test('should paginate two executions', async () => {
|
||||||
const workflow = await createWorkflow({}, owner);
|
const workflow = await createWorkflow({}, owner);
|
||||||
|
|
||||||
@@ -335,86 +296,41 @@ describe('GET /executions', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should retrieve all error executions', async () => {
|
describe('with query status', () => {
|
||||||
const workflow = await createWorkflow({}, owner);
|
type AllowedQueryStatus = 'success' | 'error' | 'canceled' | 'waiting';
|
||||||
|
test.each`
|
||||||
|
queryStatus | entityStatus
|
||||||
|
${'canceled'} | ${'canceled'}
|
||||||
|
${'error'} | ${'error'}
|
||||||
|
${'error'} | ${'crashed'}
|
||||||
|
${'success'} | ${'success'}
|
||||||
|
${'waiting'} | ${'waiting'}
|
||||||
|
`(
|
||||||
|
'should retrieve all $queryStatus executions',
|
||||||
|
async ({
|
||||||
|
queryStatus,
|
||||||
|
entityStatus,
|
||||||
|
}: { queryStatus: AllowedQueryStatus; entityStatus: ExecutionStatus }) => {
|
||||||
|
const workflow = await createWorkflow({}, owner);
|
||||||
|
|
||||||
await createSuccessfulExecution(workflow);
|
await createdExecutionWithStatus(workflow, queryStatus === 'success' ? 'error' : 'success');
|
||||||
|
|
||||||
const errorExecution = await createErrorExecution(workflow);
|
const expectedExecution = await createdExecutionWithStatus(workflow, entityStatus);
|
||||||
|
|
||||||
const response = await authOwnerAgent.get('/executions').query({
|
const response = await authOwnerAgent.get('/executions').query({
|
||||||
status: 'error',
|
status: queryStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.body.data.length).toBe(1);
|
expect(response.body.data.length).toBe(1);
|
||||||
expect(response.body.nextCursor).toBe(null);
|
expect(response.body.nextCursor).toBe(null);
|
||||||
|
|
||||||
const {
|
const { id, status } = response.body.data[0];
|
||||||
id,
|
|
||||||
finished,
|
|
||||||
mode,
|
|
||||||
retryOf,
|
|
||||||
retrySuccessId,
|
|
||||||
startedAt,
|
|
||||||
stoppedAt,
|
|
||||||
workflowId,
|
|
||||||
waitTill,
|
|
||||||
status,
|
|
||||||
} = response.body.data[0];
|
|
||||||
|
|
||||||
expect(id).toBeDefined();
|
expect(id).toBeDefined();
|
||||||
expect(finished).toBe(false);
|
expect(status).toBe(expectedExecution.status);
|
||||||
expect(mode).toEqual(errorExecution.mode);
|
},
|
||||||
expect(retrySuccessId).toBeNull();
|
);
|
||||||
expect(retryOf).toBeNull();
|
|
||||||
expect(startedAt).not.toBeNull();
|
|
||||||
expect(stoppedAt).not.toBeNull();
|
|
||||||
expect(workflowId).toBe(errorExecution.workflowId);
|
|
||||||
expect(waitTill).toBeNull();
|
|
||||||
expect(status).toBe(errorExecution.status);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return all waiting executions', async () => {
|
|
||||||
const workflow = await createWorkflow({}, owner);
|
|
||||||
|
|
||||||
await createSuccessfulExecution(workflow);
|
|
||||||
|
|
||||||
await createErrorExecution(workflow);
|
|
||||||
|
|
||||||
const waitingExecution = await createWaitingExecution(workflow);
|
|
||||||
|
|
||||||
const response = await authOwnerAgent.get('/executions').query({
|
|
||||||
status: 'waiting',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.body.data.length).toBe(1);
|
|
||||||
expect(response.body.nextCursor).toBe(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
finished,
|
|
||||||
mode,
|
|
||||||
retryOf,
|
|
||||||
retrySuccessId,
|
|
||||||
startedAt,
|
|
||||||
stoppedAt,
|
|
||||||
workflowId,
|
|
||||||
waitTill,
|
|
||||||
status,
|
|
||||||
} = response.body.data[0];
|
|
||||||
|
|
||||||
expect(id).toBeDefined();
|
|
||||||
expect(finished).toBe(false);
|
|
||||||
expect(mode).toEqual(waitingExecution.mode);
|
|
||||||
expect(retrySuccessId).toBeNull();
|
|
||||||
expect(retryOf).toBeNull();
|
|
||||||
expect(startedAt).not.toBeNull();
|
|
||||||
expect(stoppedAt).not.toBeNull();
|
|
||||||
expect(workflowId).toBe(waitingExecution.workflowId);
|
|
||||||
expect(new Date(waitTill).getTime()).toBeGreaterThan(Date.now() - 1000);
|
|
||||||
expect(status).toBe(waitingExecution.status);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should retrieve all executions of specific workflow', async () => {
|
test('should retrieve all executions of specific workflow', async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
AnnotationTagRepository,
|
AnnotationTagRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { AnnotationVote, IWorkflowBase } from 'n8n-workflow';
|
import type { AnnotationVote, ExecutionStatus, IWorkflowBase } from 'n8n-workflow';
|
||||||
|
|
||||||
import { ExecutionService } from '@/executions/execution.service';
|
import { ExecutionService } from '@/executions/execution.service';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
@@ -104,6 +104,20 @@ export async function createWaitingExecution(workflow: IWorkflowBase) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an execution with a given status in the DB and assign it to a workflow.
|
||||||
|
*/
|
||||||
|
export async function createdExecutionWithStatus(workflow: IWorkflowBase, status: ExecutionStatus) {
|
||||||
|
const execution: Partial<ExecutionEntity> = {
|
||||||
|
status,
|
||||||
|
finished: status === 'success' ? true : false,
|
||||||
|
stoppedAt: ['crashed', 'error'].includes(status) ? new Date() : undefined,
|
||||||
|
waitTill: status === 'waiting' ? new Date() : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await createExecution(execution, workflow);
|
||||||
|
}
|
||||||
|
|
||||||
export async function annotateExecution(
|
export async function annotateExecution(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
annotation: { vote?: AnnotationVote | null; tags?: string[] },
|
annotation: { vote?: AnnotationVote | null; tags?: string[] },
|
||||||
|
|||||||
Reference in New Issue
Block a user