diff --git a/package-lock.json b/package-lock.json index 41ffd423..b498d112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "animl-serverless", - "version": "2.0.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "animl-serverless", - "version": "2.0.0", + "version": "3.0.0", "license": "ISC", "dependencies": { "@apollo/server": "^4.9.3", diff --git a/src/@types/graphql.ts b/src/@types/graphql.ts index 2c8ab580..8518cb8f 100644 --- a/src/@types/graphql.ts +++ b/src/@types/graphql.ts @@ -162,6 +162,11 @@ export type CreateImagePayload = { imageAttempt?: Maybe; }; +export type CreateImageTagInput = { + imageId: Scalars['ID']['input']; + tagId: Scalars['ID']['input']; +}; + export type CreateInternalLabelInput = { bbox: Array; conf?: InputMaybe; @@ -215,6 +220,11 @@ export type CreateProjectLabelInput = { reviewerEnabled?: InputMaybe; }; +export type CreateProjectTagInput = { + color: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + export type CreateUploadInput = { originalFile: Scalars['String']['input']; partCount?: InputMaybe; @@ -256,6 +266,11 @@ export type DeleteImageCommentInput = { imageId: Scalars['ID']['input']; }; +export type DeleteImageTagInput = { + imageId: Scalars['ID']['input']; + tagId: Scalars['ID']['input']; +}; + export type DeleteImagesByFilterInput = { filters: FiltersInput; }; @@ -291,6 +306,10 @@ export type DeleteProjectLabelInput = { _id: Scalars['ID']['input']; }; +export type DeleteProjectTagInput = { + _id: Scalars['ID']['input']; +}; + export type DeleteViewInput = { viewId: Scalars['ID']['input']; }; @@ -424,6 +443,7 @@ export type Image = { path?: Maybe; projectId: Scalars['String']['output']; reviewed?: Maybe; + tags?: Maybe>; timezone: Scalars['String']['output']; userSetData?: Maybe; }; @@ -491,6 +511,11 @@ export type ImageMetadata = { timezone?: Maybe; }; +export type ImageTagsPayload = { + __typename?: 'ImageTagsPayload'; + tags?: Maybe>; +}; + export type ImagesConnection = { __typename?: 'ImagesConnection'; images: Array; @@ -567,22 +592,26 @@ export type Mutation = { createImage?: Maybe; createImageComment?: Maybe; createImageError?: Maybe; + createImageTag?: Maybe; createInternalLabels?: Maybe; createLabels?: Maybe; createObjects?: Maybe; createProject?: Maybe; createProjectLabel?: Maybe; + createProjectTag?: Maybe; createUpload?: Maybe; createUser?: Maybe; createView?: Maybe; deleteDeployment?: Maybe; deleteImageComment?: Maybe; + deleteImageTag?: Maybe; deleteImages?: Maybe; deleteImagesByFilterTask?: Maybe; deleteImagesTask?: Maybe; deleteLabels?: Maybe; deleteObjects?: Maybe; deleteProjectLabel?: Maybe; + deleteProjectTag?: Maybe; deleteView?: Maybe; redriveBatch?: Maybe; registerCamera?: Maybe; @@ -597,6 +626,7 @@ export type Mutation = { updateObjects?: Maybe; updateProject?: Maybe; updateProjectLabel?: Maybe; + updateProjectTag?: Maybe; updateUser?: Maybe; updateView?: Maybe; }; @@ -642,6 +672,11 @@ export type MutationCreateImageErrorArgs = { }; +export type MutationCreateImageTagArgs = { + input: CreateImageTagInput; +}; + + export type MutationCreateInternalLabelsArgs = { input: CreateInternalLabelsInput; }; @@ -667,6 +702,11 @@ export type MutationCreateProjectLabelArgs = { }; +export type MutationCreateProjectTagArgs = { + input: CreateProjectTagInput; +}; + + export type MutationCreateUploadArgs = { input: CreateUploadInput; }; @@ -692,6 +732,11 @@ export type MutationDeleteImageCommentArgs = { }; +export type MutationDeleteImageTagArgs = { + input: DeleteImageTagInput; +}; + + export type MutationDeleteImagesArgs = { input: DeleteImagesInput; }; @@ -722,6 +767,11 @@ export type MutationDeleteProjectLabelArgs = { }; +export type MutationDeleteProjectTagArgs = { + input: DeleteProjectTagInput; +}; + + export type MutationDeleteViewArgs = { input: DeleteViewInput; }; @@ -792,6 +842,11 @@ export type MutationUpdateProjectLabelArgs = { }; +export type MutationUpdateProjectTagArgs = { + input: UpdateProjectTagInput; +}; + + export type MutationUpdateUserArgs = { input: UpdateUserInput; }; @@ -864,6 +919,7 @@ export type Project = { description?: Maybe; labels?: Maybe>; name: Scalars['String']['output']; + tags?: Maybe>; timezone: Scalars['String']['output']; views: Array; }; @@ -894,6 +950,18 @@ export type ProjectRegistration = { projectId: Scalars['String']['output']; }; +export type ProjectTag = { + __typename?: 'ProjectTag'; + _id: Scalars['ID']['output']; + color: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type ProjectTagsPayload = { + __typename?: 'ProjectTagsPayload'; + tags?: Maybe>; +}; + export type Query = { __typename?: 'Query'; batches?: Maybe; @@ -1167,6 +1235,12 @@ export type UpdateProjectLabelInput = { reviewerEnabled?: InputMaybe; }; +export type UpdateProjectTagInput = { + _id: Scalars['ID']['input']; + color: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + export type UpdateUserInput = { roles: Array; username: Scalars['String']['input']; diff --git a/src/api/auth/roles.ts b/src/api/auth/roles.ts index c5b35980..78c455c9 100644 --- a/src/api/auth/roles.ts +++ b/src/api/auth/roles.ts @@ -15,6 +15,7 @@ const WRITE_DEPLOYMENTS_ROLES = [MANAGER]; const WRITE_AUTOMATION_RULES_ROLES = [MANAGER]; const WRITE_CAMERA_REGISTRATION_ROLES = [MANAGER]; const WRITE_CAMERA_SERIAL_NUMBER_ROLES = [MANAGER]; +const WRITE_TAGS_ROLES = [MANAGER, MEMBER]; export { READ_TASKS_ROLES, @@ -30,4 +31,5 @@ export { WRITE_AUTOMATION_RULES_ROLES, WRITE_CAMERA_REGISTRATION_ROLES, WRITE_CAMERA_SERIAL_NUMBER_ROLES, + WRITE_TAGS_ROLES, }; diff --git a/src/api/db/models/Image.ts b/src/api/db/models/Image.ts index 140afe6b..e2343190 100644 --- a/src/api/db/models/Image.ts +++ b/src/api/db/models/Image.ts @@ -9,7 +9,7 @@ import GraphQLError, { NotFoundError, } from '../../errors.js'; import { BulkWriteResult } from 'mongodb'; -import mongoose, { HydratedDocument } from 'mongoose'; +import mongoose, { HydratedDocument, UpdateWriteOpResult } from 'mongoose'; import MongoPaging, { AggregationOutput } from 'mongo-cursor-pagination'; import { TaskModel } from './Task.js'; import { ObjectSchema } from '../schemas/shared/index.js'; @@ -28,6 +28,7 @@ import { WRITE_IMAGES_ROLES, WRITE_COMMENTS_ROLES, EXPORT_DATA_ROLES, + WRITE_TAGS_ROLES, } from '../../auth/roles.js'; import { buildPipeline, @@ -475,6 +476,86 @@ export class ImageModel { } } + static async createTag( + input: gql.CreateImageTagInput, + context: Pick, + ): Promise<{ tags: mongoose.Types.ObjectId[] }> { + try { + const image = await ImageModel.queryById(input.imageId, context); + + if (!image.tags) { + image.tags = [] as any as mongoose.Types.DocumentArray; + } + + image.tags.push(new mongoose.Types.ObjectId(input.tagId)); + await image.save(); + + return { tags: image.tags }; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + + static async deleteTag( + input: gql.DeleteImageTagInput, + context: Pick, + ): Promise<{ tags: mongoose.Types.ObjectId[] }> { + try { + const image = await ImageModel.queryById(input.imageId, context); + + const tag = image.tags?.filter((t) => idMatch(t, input.tagId))[0]; + if (!tag) throw new NotFoundError('Tag not found on image'); + + image.tags = image.tags.filter( + (t) => !idMatch(t, input.tagId), + ) as mongoose.Types.ObjectId[]; + + await image.save(); + + return { tags: image.tags }; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + + static async countProjectTag( + input: { tagId: string }, + context: Pick, + ): Promise { + try { + const projectId = context.user['curr_project']!; + const count = await Image.countDocuments({ + projectId: projectId, + tags: new ObjectId(input.tagId) + }); + + return count; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + + static async deleteProjectTag( + input: { tagId: string }, + context: Pick, + ): Promise { + try { + const projectId = context.user['curr_project']!; + const res = await Image.updateMany({ + projectId: projectId + }, { + $pull: { tags: new mongoose.Types.ObjectId(input.tagId) } + }); + return res; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + /** * Finds Image records and creates new Object subdocuments on them * It's used by frontend when creating new empty objects and when adding @@ -1160,6 +1241,16 @@ export default class AuthedImageModel extends BaseAuthedModel { return ImageModel.queryByFilter(...args); } + @roleCheck(WRITE_TAGS_ROLES) + createTag(...args: MethodParams) { + return ImageModel.createTag(...args); + } + + @roleCheck(WRITE_TAGS_ROLES) + deleteTag(...args: MethodParams) { + return ImageModel.deleteTag(...args); + } + @roleCheck(WRITE_COMMENTS_ROLES) createComment(...args: MethodParams) { return ImageModel.createComment(...args); diff --git a/src/api/db/models/Project.ts b/src/api/db/models/Project.ts index 1daeaa4b..7e8897ab 100644 --- a/src/api/db/models/Project.ts +++ b/src/api/db/models/Project.ts @@ -7,6 +7,7 @@ import GraphQLError, { DeleteLabelError, ForbiddenError, DBValidationError, + DeleteTagError, } from '../../errors.js'; import { DateTime } from 'luxon'; import Project, { @@ -15,6 +16,7 @@ import Project, { IAutomationRule, ProjectLabelSchema, ProjectSchema, + ProjectTagSchema, ViewSchema, } from '../schemas/Project.js'; import { UserModel } from './User.js'; @@ -37,6 +39,10 @@ import { TaskSchema } from '../schemas/Task.js'; // when removing a label from a project const MAX_LABEL_DELETE = 500; +// The max number of tagged images that can be deleted +// when removing a tag from a project +const MAX_TAG_DELETE = 50000; + const ObjectId = mongoose.Types.ObjectId; export class ProjectModel { @@ -587,6 +593,95 @@ export class ProjectModel { } } + static async createTag( + input: gql.CreateProjectTagInput, + context: Pick, + ): Promise<{ tags: mongoose.Types.DocumentArray }> { + try { + const project = await this.queryById(context.user['curr_project']!); + + if (!project.tags) { + project.tags = [] as any as mongoose.Types.DocumentArray; + } + + if ( + project.tags.filter((tag) => { + return tag.name.toLowerCase() === input.name.toLowerCase(); + }).length + ) { + throw new DBValidationError( + 'A tag with that name already exists, avoid creating tags with duplicate names', + ); + } + + project.tags.push(input); + + await project.save(); + + return { tags: project.tags }; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + + static async deleteTag( + input: gql.DeleteProjectTagInput, + context: Pick, + ): Promise<{ tags: mongoose.Types.DocumentArray }> { + try { + const project = await this.queryById(context.user['curr_project']); + + const tag = project.tags?.find((t) => t._id.toString() === input._id.toString()); + if (!tag) { + throw new DeleteTagError('Tag not found on project'); + } + + const toRemoveCount = await ImageModel.countProjectTag({ tagId: input._id }, context); + + if (toRemoveCount > MAX_TAG_DELETE) { + const msg = + `This tag is already in extensive use (>${MAX_TAG_DELETE} images) and cannot be ` + + ' automatically deleted. Please contact nathaniel[dot]rindlaub@tnc[dot]org to request that it be manually deleted.'; + throw new DeleteTagError(msg); + } + + await ImageModel.deleteProjectTag({ tagId: input._id }, context); + + project.tags.splice(project.tags.indexOf(tag), 1); + + await project.save(); + + return { tags: project.tags }; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + + static async updateTag( + input: gql.UpdateProjectTagInput, + context: Pick, + ): Promise<{ tags: mongoose.Types.DocumentArray }> { + try { + const project = await this.queryById(context.user['curr_project']!); + + const tag = project.tags.find((l) => l._id.toString() === input._id.toString()); + if (!tag) { + throw new NotFoundError('Tag not found on project'); + } + + Object.assign(tag, input); + + await project.save(); + + return { tags: project.tags }; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + static async createLabel( input: gql.CreateProjectLabelInput, context: Pick, @@ -700,6 +795,21 @@ export default class AuthedProjectModel extends BaseAuthedModel { return ProjectModel.createProject(...args); } + @roleCheck(WRITE_PROJECT_ROLES) + deleteTag(...args: MethodParams) { + return ProjectModel.deleteTag(...args); + } + + @roleCheck(WRITE_PROJECT_ROLES) + createTag(...args: MethodParams) { + return ProjectModel.createTag(...args); + } + + @roleCheck(WRITE_PROJECT_ROLES) + updateTag(...args: MethodParams) { + return ProjectModel.updateTag(...args); + } + @roleCheck(WRITE_PROJECT_ROLES) deleteLabel(...args: MethodParams) { return ProjectModel.deleteLabel(...args); diff --git a/src/api/db/models/utils.ts b/src/api/db/models/utils.ts index 32daf52f..7f86d95c 100644 --- a/src/api/db/models/utils.ts +++ b/src/api/db/models/utils.ts @@ -33,6 +33,18 @@ export function buildImgUrl(image: ImageSchema, config: Config, size = 'original return url + '/' + size + '/' + id + '-' + size + '.' + ext; } +export function buildTagPipeline(tags: string[]): PipelineStage[] { + const pipeline: PipelineStage[] = []; + + pipeline.push({ + $match: { + tags: { $in: tags } + } + }); + + return pipeline +} + export function buildLabelPipeline(labels: string[]): PipelineStage[] { const pipeline: PipelineStage[] = []; diff --git a/src/api/db/schemas/Image.ts b/src/api/db/schemas/Image.ts index a7aadba3..b945330c 100644 --- a/src/api/db/schemas/Image.ts +++ b/src/api/db/schemas/Image.ts @@ -1,6 +1,7 @@ import mongoose from 'mongoose'; import MongoPaging from 'mongo-cursor-pagination'; import { LocationSchema, ObjectSchema } from './shared/index.js'; +import { randomUUID } from 'node:crypto'; const Schema = mongoose.Schema; @@ -42,6 +43,7 @@ const ImageSchema = new Schema({ reviewed: { type: Boolean }, objects: { type: [ObjectSchema] }, comments: { type: [ImageCommentSchema] }, + tags: { type: [mongoose.Schema.Types.ObjectId] }, }); ImageSchema.plugin(MongoPaging.mongoosePlugin); diff --git a/src/api/db/schemas/Project.ts b/src/api/db/schemas/Project.ts index 3a2cb02f..af0e07d1 100644 --- a/src/api/db/schemas/Project.ts +++ b/src/api/db/schemas/Project.ts @@ -71,6 +71,12 @@ const ProjectLabelSchema = new Schema({ ml: { type: Boolean, required: true, default: false }, }); +const ProjectTagSchema = new Schema({ + _id: { type: Schema.Types.ObjectId, required: true, auto: true }, + name: { type: String, required: true }, + color: { type: String, required: true } +}); + const CameraConfigSchema = new Schema({ _id: { type: String, required: true } /* _id is serial number */, deployments: { type: [DeploymentSchema] }, @@ -103,6 +109,7 @@ const ProjectSchema = new Schema({ ], required: true, }, + tags: { type: [ProjectTagSchema] }, }); export default mongoose.model('Project', ProjectSchema); @@ -112,6 +119,7 @@ export type FiltersSchema = mongoose.InferSchemaType; export type ViewSchema = mongoose.InferSchemaType; export type DeploymentSchema = mongoose.InferSchemaType; export type ProjectLabelSchema = mongoose.InferSchemaType; +export type ProjectTagSchema = mongoose.InferSchemaType; export type CameraConfigSchema = mongoose.InferSchemaType; export type ProjectSchema = mongoose.InferSchemaType; diff --git a/src/api/errors.ts b/src/api/errors.ts index 54acd8a7..fea42bbb 100644 --- a/src/api/errors.ts +++ b/src/api/errors.ts @@ -94,6 +94,17 @@ export class DeleteLabelError extends GraphQLError { } } +export class DeleteTagError extends GraphQLError { + constructor(message = 'DeleteTagError', properties = {}) { + super(message, { + extensions: { + code: 'DELETE_TAG_FAILED', + ...properties, + }, + }); + } +} + export class CameraRegistrationError extends GraphQLError { constructor(message = 'CameraRegistrationError', properties = {}) { super(message, { diff --git a/src/api/resolvers/Mutation.ts b/src/api/resolvers/Mutation.ts index 94708d5b..5f2c980d 100644 --- a/src/api/resolvers/Mutation.ts +++ b/src/api/resolvers/Mutation.ts @@ -115,6 +115,22 @@ export default { return context.models.Image.createComment(input, context); }, + createImageTag: async ( + _: unknown, + { input }: gql.MutationCreateImageTagArgs, + context: Context, + ): Promise => { + return context.models.Image.createTag(input, context); + }, + + deleteImageTag: async ( + _: unknown, + { input }: gql.MutationDeleteImageTagArgs, + context: Context, + ): Promise => { + return context.models.Image.deleteTag(input, context); + }, + createImage: async ( _: unknown, { input }: gql.MutationCreateImageArgs, @@ -190,6 +206,30 @@ export default { return { project }; }, + createProjectTag: async ( + _: unknown, + { input }: gql.MutationCreateProjectTagArgs, + context: Context, + ): Promise => { + return await context.models.Project.createTag(input, context); + }, + + deleteProjectTag: async ( + _: unknown, + { input }: gql.MutationDeleteProjectTagArgs, + context: Context, + ): Promise => { + return await context.models.Project.deleteTag(input, context); + }, + + updateProjectTag: async ( + _: unknown, + { input }: gql.MutationUpdateProjectTagArgs, + context: Context, + ): Promise => { + return await context.models.Project.updateTag(input, context); + }, + createProjectLabel: async ( _: unknown, { input }: gql.MutationCreateProjectLabelArgs, diff --git a/src/api/type-defs/inputs/CreateImageTagInput.ts b/src/api/type-defs/inputs/CreateImageTagInput.ts new file mode 100644 index 00000000..642b59ee --- /dev/null +++ b/src/api/type-defs/inputs/CreateImageTagInput.ts @@ -0,0 +1,6 @@ +export default /* GraphQL */ ` + input CreateImageTagInput { + imageId: ID! + tagId: ID! + } +`; diff --git a/src/api/type-defs/inputs/CreateProjectTagInput.ts b/src/api/type-defs/inputs/CreateProjectTagInput.ts new file mode 100644 index 00000000..dc99609c --- /dev/null +++ b/src/api/type-defs/inputs/CreateProjectTagInput.ts @@ -0,0 +1,6 @@ +export default /* GraphQL */ ` + input CreateProjectTagInput { + name: String! + color: String! + } +`; diff --git a/src/api/type-defs/inputs/DeleteImageTagInput.ts b/src/api/type-defs/inputs/DeleteImageTagInput.ts new file mode 100644 index 00000000..8b2e2c3e --- /dev/null +++ b/src/api/type-defs/inputs/DeleteImageTagInput.ts @@ -0,0 +1,6 @@ +export default /* GraphQL */ ` + input DeleteImageTagInput { + imageId: ID! + tagId: ID! + } +`; diff --git a/src/api/type-defs/inputs/DeleteProjectTagInput.ts b/src/api/type-defs/inputs/DeleteProjectTagInput.ts new file mode 100644 index 00000000..2496eacb --- /dev/null +++ b/src/api/type-defs/inputs/DeleteProjectTagInput.ts @@ -0,0 +1,5 @@ +export default /* GraphQL */ ` + input DeleteProjectTagInput { + _id: ID! + } +`; diff --git a/src/api/type-defs/inputs/UpdateProjectTagInput.ts b/src/api/type-defs/inputs/UpdateProjectTagInput.ts new file mode 100644 index 00000000..42aaf840 --- /dev/null +++ b/src/api/type-defs/inputs/UpdateProjectTagInput.ts @@ -0,0 +1,7 @@ +export default /* GraphQL */ ` + input UpdateProjectTagInput { + _id: ID! + name: String! + color: String! + } +`; diff --git a/src/api/type-defs/objects/Image.ts b/src/api/type-defs/objects/Image.ts index 65c86a1a..ab3c43e5 100644 --- a/src/api/type-defs/objects/Image.ts +++ b/src/api/type-defs/objects/Image.ts @@ -24,5 +24,6 @@ export default /* GraphQL */ ` reviewed: Boolean objects: [Object!] comments: [ImageComment!] + tags: [ID!] } `; diff --git a/src/api/type-defs/objects/Project.ts b/src/api/type-defs/objects/Project.ts index 8eb10286..bb9bf673 100644 --- a/src/api/type-defs/objects/Project.ts +++ b/src/api/type-defs/objects/Project.ts @@ -22,6 +22,12 @@ export default /* GraphQL */ ` ml: Boolean! } + type ProjectTag { + _id: ID! + name: String! + color: String! + } + type Project { _id: String! name: String! @@ -31,6 +37,7 @@ export default /* GraphQL */ ` automationRules: [AutomationRule!] cameraConfigs: [CameraConfig!] labels: [ProjectLabel!] + tags: [ProjectTag!] availableMLModels: [String!] } `; diff --git a/src/api/type-defs/payloads/ImageTagsPayload.ts b/src/api/type-defs/payloads/ImageTagsPayload.ts new file mode 100644 index 00000000..a8706ae4 --- /dev/null +++ b/src/api/type-defs/payloads/ImageTagsPayload.ts @@ -0,0 +1,5 @@ +export default /* GraphQL */ ` + type ImageTagsPayload { + tags: [ID!] + } +`; diff --git a/src/api/type-defs/payloads/ProjectTagsPayload.ts b/src/api/type-defs/payloads/ProjectTagsPayload.ts new file mode 100644 index 00000000..40be0f51 --- /dev/null +++ b/src/api/type-defs/payloads/ProjectTagsPayload.ts @@ -0,0 +1,5 @@ +export default /* GraphQL */ ` + type ProjectTagsPayload { + tags: [ProjectTag!] + } +`; diff --git a/src/api/type-defs/root/Mutation.ts b/src/api/type-defs/root/Mutation.ts index 201d76de..a19b9517 100644 --- a/src/api/type-defs/root/Mutation.ts +++ b/src/api/type-defs/root/Mutation.ts @@ -26,6 +26,13 @@ export default /* GraphQL */ ` updateProjectLabel(input: UpdateProjectLabelInput!): ProjectLabelPayload deleteProjectLabel(input: DeleteProjectLabelInput!): StandardPayload + createProjectTag(input: CreateProjectTagInput!): ProjectTagsPayload + deleteProjectTag(input: DeleteProjectTagInput!): ProjectTagsPayload + updateProjectTag(input: UpdateProjectTagInput!): ProjectTagsPayload + + createImageTag(input: CreateImageTagInput!): ImageTagsPayload + deleteImageTag(input: DeleteImageTagInput!): ImageTagsPayload + createBatchError(input: CreateBatchErrorInput!): BatchError createImageError(input: CreateImageErrorInput!): ImageError clearImageErrors(input: ClearImageErrorsInput!): StandardPayload