From 8e31d77cf08be60fb738062b7f42a1d96e10b907 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Tue, 22 Oct 2024 22:37:47 -0400 Subject: [PATCH 01/10] feat(138.b): add project tag schema and update project schema --- src/api/db/schemas/Project.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/api/db/schemas/Project.ts b/src/api/db/schemas/Project.ts index 3a2cb02f..e9659b97 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: String, required: true, default: randomUUID }, + 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; From 52b1676b3499f35940a1a9abc9f52d5bfd169229 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Wed, 23 Oct 2024 20:33:42 -0400 Subject: [PATCH 02/10] feat(138.b): add mutation for creating project tag --- src/@types/graphql.ts | 24 +++++++++++++ src/api/db/models/Project.ts | 34 +++++++++++++++++++ src/api/resolvers/Mutation.ts | 9 +++++ .../type-defs/inputs/CreateProjectTagInput.ts | 6 ++++ src/api/type-defs/objects/Project.ts | 7 ++++ .../type-defs/payloads/ProjectTagsPayload.ts | 5 +++ src/api/type-defs/root/Mutation.ts | 2 ++ 7 files changed, 87 insertions(+) create mode 100644 src/api/type-defs/inputs/CreateProjectTagInput.ts create mode 100644 src/api/type-defs/payloads/ProjectTagsPayload.ts diff --git a/src/@types/graphql.ts b/src/@types/graphql.ts index 51a06522..34d7f30d 100644 --- a/src/@types/graphql.ts +++ b/src/@types/graphql.ts @@ -215,6 +215,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; @@ -564,6 +569,7 @@ export type Mutation = { createObjects?: Maybe; createProject?: Maybe; createProjectLabel?: Maybe; + createProjectTag?: Maybe; createUpload?: Maybe; createUser?: Maybe; createView?: Maybe; @@ -657,6 +663,11 @@ export type MutationCreateProjectLabelArgs = { }; +export type MutationCreateProjectTagArgs = { + input: CreateProjectTagInput; +}; + + export type MutationCreateUploadArgs = { input: CreateUploadInput; }; @@ -844,6 +855,7 @@ export type Project = { description?: Maybe; labels?: Maybe>; name: Scalars['String']['output']; + tags?: Maybe>; timezone: Scalars['String']['output']; views: Array; }; @@ -874,6 +886,18 @@ export type ProjectRegistration = { projectId: Scalars['String']['output']; }; +export type ProjectTag = { + __typename?: 'ProjectTag'; + _id: Scalars['String']['output']; + color: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type ProjectTagsPayload = { + __typename?: 'ProjectTagsPayload'; + tags?: Maybe>>; +}; + export type Query = { __typename?: 'Query'; batches?: Maybe; diff --git a/src/api/db/models/Project.ts b/src/api/db/models/Project.ts index 1daeaa4b..1d053532 100644 --- a/src/api/db/models/Project.ts +++ b/src/api/db/models/Project.ts @@ -15,6 +15,7 @@ import Project, { IAutomationRule, ProjectLabelSchema, ProjectSchema, + ProjectTagSchema, ViewSchema, } from '../schemas/Project.js'; import { UserModel } from './User.js'; @@ -587,6 +588,34 @@ 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.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 createLabel( input: gql.CreateProjectLabelInput, context: Pick, @@ -705,6 +734,11 @@ export default class AuthedProjectModel extends BaseAuthedModel { return ProjectModel.deleteLabel(...args); } + @roleCheck(WRITE_PROJECT_ROLES) + createTag(...args: MethodParams) { + return ProjectModel.createTag(...args); + } + @roleCheck(WRITE_PROJECT_ROLES) createLabel(...args: MethodParams) { return ProjectModel.createLabel(...args); diff --git a/src/api/resolvers/Mutation.ts b/src/api/resolvers/Mutation.ts index b7d7f90a..f6182725 100644 --- a/src/api/resolvers/Mutation.ts +++ b/src/api/resolvers/Mutation.ts @@ -174,6 +174,15 @@ export default { return { project }; }, + createProjectTag: async ( + _: unknown, + { input }: gql.MutationCreateProjectTagArgs, + context: Context, + ): Promise => { + const tags = await context.models.Project.createTag(input, context); + return { tags }; + }, + createProjectLabel: async ( _: unknown, { input }: gql.MutationCreateProjectLabelArgs, 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/objects/Project.ts b/src/api/type-defs/objects/Project.ts index 8eb10286..4b46746f 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: String! + 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/ProjectTagsPayload.ts b/src/api/type-defs/payloads/ProjectTagsPayload.ts new file mode 100644 index 00000000..838959d0 --- /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 2aa2c40f..a6203927 100644 --- a/src/api/type-defs/root/Mutation.ts +++ b/src/api/type-defs/root/Mutation.ts @@ -24,6 +24,8 @@ export default /* GraphQL */ ` updateProjectLabel(input: UpdateProjectLabelInput!): ProjectLabelPayload deleteProjectLabel(input: DeleteProjectLabelInput!): StandardPayload + createProjectTag(input: CreateProjectTagInput!): ProjectTagsPayload + createBatchError(input: CreateBatchErrorInput!): BatchError createImageError(input: CreateImageErrorInput!): ImageError clearImageErrors(input: ClearImageErrorsInput!): StandardPayload From 4ea5bd5f2d8fa402f548c139dad38f0c0937b4c8 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Thu, 24 Oct 2024 20:53:49 -0400 Subject: [PATCH 03/10] feat(138.b): add delete project tag resolver and type defs --- src/@types/graphql.ts | 12 +++++- src/api/db/models/Image.ts | 17 ++++++++ src/api/db/models/Project.ts | 40 +++++++++++++++++++ src/api/db/models/utils.ts | 12 ++++++ src/api/errors.ts | 11 +++++ src/api/resolvers/Mutation.ts | 12 +++++- .../type-defs/inputs/DeleteProjectTagInput.ts | 5 +++ .../type-defs/payloads/ProjectTagsPayload.ts | 2 +- src/api/type-defs/root/Mutation.ts | 1 + 9 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 src/api/type-defs/inputs/DeleteProjectTagInput.ts diff --git a/src/@types/graphql.ts b/src/@types/graphql.ts index 34d7f30d..7a5c039f 100644 --- a/src/@types/graphql.ts +++ b/src/@types/graphql.ts @@ -288,6 +288,10 @@ export type DeleteProjectLabelInput = { _id: Scalars['ID']['input']; }; +export type DeleteProjectTagInput = { + _id: Scalars['ID']['input']; +}; + export type DeleteViewInput = { viewId: Scalars['ID']['input']; }; @@ -579,6 +583,7 @@ export type Mutation = { deleteLabels?: Maybe; deleteObjects?: Maybe; deleteProjectLabel?: Maybe; + deleteProjectTag?: Maybe; deleteView?: Maybe; redriveBatch?: Maybe; registerCamera?: Maybe; @@ -713,6 +718,11 @@ export type MutationDeleteProjectLabelArgs = { }; +export type MutationDeleteProjectTagArgs = { + input: DeleteProjectTagInput; +}; + + export type MutationDeleteViewArgs = { input: DeleteViewInput; }; @@ -895,7 +905,7 @@ export type ProjectTag = { export type ProjectTagsPayload = { __typename?: 'ProjectTagsPayload'; - tags?: Maybe>>; + tags?: Maybe>; }; export type Query = { diff --git a/src/api/db/models/Image.ts b/src/api/db/models/Image.ts index 8f402177..12d68942 100644 --- a/src/api/db/models/Image.ts +++ b/src/api/db/models/Image.ts @@ -85,6 +85,23 @@ export class ImageModel { return res[0] ? res[0].count : 0; } + static async countImagesByTag( + tags: string[], + context: Pick, + ): Promise { + if (tags.length === 0) { + return 0; + } + const pipeline = [ + { $match: { projectId: ontext.user['curr_project'] } }, + ...buildTagPipeline(tags), + { $count: 'count' }, + ]; + + const res = await Project.aggregate(pipeline); + return res[0] ? res[0].count : 0; + } + static async queryById( _id: string, context: Pick, diff --git a/src/api/db/models/Project.ts b/src/api/db/models/Project.ts index 1d053532..f173b3de 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, { @@ -38,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 = 500; + const ObjectId = mongoose.Types.ObjectId; export class ProjectModel { @@ -595,6 +600,10 @@ export class ProjectModel { 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(); @@ -643,6 +652,32 @@ export class ProjectModel { } } + 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'); + } + + // TODO implement pipeline + // TODO implement delete from existing images + + 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 deleteLabel( input: gql.DeleteProjectLabelInput, context: Pick, @@ -734,6 +769,11 @@ export default class AuthedProjectModel extends BaseAuthedModel { return ProjectModel.deleteLabel(...args); } + @roleCheck(WRITE_PROJECT_ROLES) + deleteTag(...args: MethodParams) { + return ProjectModel.deleteTag(...args); + } + @roleCheck(WRITE_PROJECT_ROLES) createTag(...args: MethodParams) { return ProjectModel.createTag(...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/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 f6182725..9bbe7e93 100644 --- a/src/api/resolvers/Mutation.ts +++ b/src/api/resolvers/Mutation.ts @@ -179,8 +179,16 @@ export default { { input }: gql.MutationCreateProjectTagArgs, context: Context, ): Promise => { - const tags = await context.models.Project.createTag(input, context); - return { tags }; + const projectTags = await context.models.Project.createTag(input, context); + return projectTags; + }, + + deleteProjectTag: async ( + _: unknown, + { input }: gql.MutationDeleteProjectTagArgs, + context: Context, + ): Promise => { + return context.models.Project.deleteTag(input, context); }, createProjectLabel: async ( 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/payloads/ProjectTagsPayload.ts b/src/api/type-defs/payloads/ProjectTagsPayload.ts index 838959d0..40be0f51 100644 --- a/src/api/type-defs/payloads/ProjectTagsPayload.ts +++ b/src/api/type-defs/payloads/ProjectTagsPayload.ts @@ -1,5 +1,5 @@ export default /* GraphQL */ ` type ProjectTagsPayload { - tags: [ProjectTag] + tags: [ProjectTag!] } `; diff --git a/src/api/type-defs/root/Mutation.ts b/src/api/type-defs/root/Mutation.ts index a6203927..487f5b01 100644 --- a/src/api/type-defs/root/Mutation.ts +++ b/src/api/type-defs/root/Mutation.ts @@ -25,6 +25,7 @@ export default /* GraphQL */ ` deleteProjectLabel(input: DeleteProjectLabelInput!): StandardPayload createProjectTag(input: CreateProjectTagInput!): ProjectTagsPayload + deleteProjectTag(input: DeleteProjectTagInput!): ProjectTagsPayload createBatchError(input: CreateBatchErrorInput!): BatchError createImageError(input: CreateImageErrorInput!): ImageError From f851495b8f67f192cdbccb5ff4cca6648667fd25 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Thu, 24 Oct 2024 21:07:34 -0400 Subject: [PATCH 04/10] feat(138.b): add resolver and model methods for update tag --- src/@types/graphql.ts | 12 +++ src/api/db/models/Project.ts | 84 ++++++++++++------- src/api/resolvers/Mutation.ts | 13 ++- .../type-defs/inputs/UpdateProjectTagInput.ts | 7 ++ src/api/type-defs/root/Mutation.ts | 1 + 5 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 src/api/type-defs/inputs/UpdateProjectTagInput.ts diff --git a/src/@types/graphql.ts b/src/@types/graphql.ts index 7a5c039f..1762b825 100644 --- a/src/@types/graphql.ts +++ b/src/@types/graphql.ts @@ -598,6 +598,7 @@ export type Mutation = { updateObjects?: Maybe; updateProject?: Maybe; updateProjectLabel?: Maybe; + updateProjectTag?: Maybe; updateUser?: Maybe; updateView?: Maybe; }; @@ -793,6 +794,11 @@ export type MutationUpdateProjectLabelArgs = { }; +export type MutationUpdateProjectTagArgs = { + input: UpdateProjectTagInput; +}; + + export type MutationUpdateUserArgs = { input: UpdateUserInput; }; @@ -1181,6 +1187,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/db/models/Project.ts b/src/api/db/models/Project.ts index f173b3de..8fdd3bcf 100644 --- a/src/api/db/models/Project.ts +++ b/src/api/db/models/Project.ts @@ -625,49 +625,45 @@ export class ProjectModel { } } - static async createLabel( - input: gql.CreateProjectLabelInput, + static async deleteTag( + input: gql.DeleteProjectTagInput, context: Pick, - ): Promise> { + ): Promise<{ tags: mongoose.Types.DocumentArray }> { try { - const project = await this.queryById(context.user['curr_project']!); + const project = await this.queryById(context.user['curr_project']); - if ( - project.labels.filter((label) => { - return label.name.toLowerCase() === input.name.toLowerCase(); - }).length - ) - throw new DBValidationError( - 'A label with that name already exists, avoid creating labels with duplicate names', - ); + const tag = project.tags?.find((t) => t._id.toString() === input._id.toString()); + if (!tag) { + throw new DeleteTagError('Tag not found on project'); + } - project.labels.push(input); + // TODO implement pipeline + // TODO implement delete from existing images + + project.tags.splice(project.tags.indexOf(tag), 1); await project.save(); - return project.labels.pop()!; + return { tags: project.tags }; } catch (err) { if (err instanceof GraphQLError) throw err; throw new InternalServerError(err as string); } } - static async deleteTag( - input: gql.DeleteProjectTagInput, + static async updateTag( + input: gql.UpdateProjectTagInput, context: Pick, ): Promise<{ tags: mongoose.Types.DocumentArray }> { try { - const project = await this.queryById(context.user['curr_project']); + const project = await this.queryById(context.user['curr_project']!); - const tag = project.tags?.find((t) => t._id.toString() === input._id.toString()); + const tag = project.tags.find((l) => l._id.toString() === input._id.toString()); if (!tag) { - throw new DeleteTagError('Tag not found on project'); + throw new NotFoundError('Tag not found on project'); } - // TODO implement pipeline - // TODO implement delete from existing images - - project.tags.splice(project.tags.indexOf(tag), 1); + Object.assign(tag, input); await project.save(); @@ -678,6 +674,33 @@ export class ProjectModel { } } + static async createLabel( + input: gql.CreateProjectLabelInput, + context: Pick, + ): Promise> { + try { + const project = await this.queryById(context.user['curr_project']!); + + if ( + project.labels.filter((label) => { + return label.name.toLowerCase() === input.name.toLowerCase(); + }).length + ) + throw new DBValidationError( + 'A label with that name already exists, avoid creating labels with duplicate names', + ); + + project.labels.push(input); + + await project.save(); + + return project.labels.pop()!; + } catch (err) { + if (err instanceof GraphQLError) throw err; + throw new InternalServerError(err as string); + } + } + static async deleteLabel( input: gql.DeleteProjectLabelInput, context: Pick, @@ -764,11 +787,6 @@ export default class AuthedProjectModel extends BaseAuthedModel { return ProjectModel.createProject(...args); } - @roleCheck(WRITE_PROJECT_ROLES) - deleteLabel(...args: MethodParams) { - return ProjectModel.deleteLabel(...args); - } - @roleCheck(WRITE_PROJECT_ROLES) deleteTag(...args: MethodParams) { return ProjectModel.deleteTag(...args); @@ -779,6 +797,16 @@ export default class AuthedProjectModel extends BaseAuthedModel { 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); + } + @roleCheck(WRITE_PROJECT_ROLES) createLabel(...args: MethodParams) { return ProjectModel.createLabel(...args); diff --git a/src/api/resolvers/Mutation.ts b/src/api/resolvers/Mutation.ts index 9bbe7e93..a9ca3540 100644 --- a/src/api/resolvers/Mutation.ts +++ b/src/api/resolvers/Mutation.ts @@ -179,8 +179,7 @@ export default { { input }: gql.MutationCreateProjectTagArgs, context: Context, ): Promise => { - const projectTags = await context.models.Project.createTag(input, context); - return projectTags; + return await context.models.Project.createTag(input, context); }, deleteProjectTag: async ( @@ -188,7 +187,15 @@ export default { { input }: gql.MutationDeleteProjectTagArgs, context: Context, ): Promise => { - return context.models.Project.deleteTag(input, context); + 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 ( 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/root/Mutation.ts b/src/api/type-defs/root/Mutation.ts index 487f5b01..6f44be93 100644 --- a/src/api/type-defs/root/Mutation.ts +++ b/src/api/type-defs/root/Mutation.ts @@ -26,6 +26,7 @@ export default /* GraphQL */ ` createProjectTag(input: CreateProjectTagInput!): ProjectTagsPayload deleteProjectTag(input: DeleteProjectTagInput!): ProjectTagsPayload + updateProjectTag(input: UpdateProjectTagInput!): ProjectTagsPayload createBatchError(input: CreateBatchErrorInput!): BatchError createImageError(input: CreateImageErrorInput!): ImageError From 8e83fd53eda18bf7fcbf6a724d0c7339182170d6 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Thu, 24 Oct 2024 23:30:12 -0400 Subject: [PATCH 05/10] fix: remove unneeded code --- src/api/db/models/Image.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/api/db/models/Image.ts b/src/api/db/models/Image.ts index 12d68942..8f402177 100644 --- a/src/api/db/models/Image.ts +++ b/src/api/db/models/Image.ts @@ -85,23 +85,6 @@ export class ImageModel { return res[0] ? res[0].count : 0; } - static async countImagesByTag( - tags: string[], - context: Pick, - ): Promise { - if (tags.length === 0) { - return 0; - } - const pipeline = [ - { $match: { projectId: ontext.user['curr_project'] } }, - ...buildTagPipeline(tags), - { $count: 'count' }, - ]; - - const res = await Project.aggregate(pipeline); - return res[0] ? res[0].count : 0; - } - static async queryById( _id: string, context: Pick, From 3cb80572c350aeca2ee31e1c4fce3b6c8f65c4d9 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Thu, 31 Oct 2024 08:47:38 -0400 Subject: [PATCH 06/10] feat(138): add resolvers, schema, type defs for creating and deleting image tags --- src/@types/graphql.ts | 30 +++++++++- src/api/auth/roles.ts | 2 + src/api/db/models/Image.ts | 55 +++++++++++++++++++ src/api/db/schemas/Image.ts | 2 + src/api/db/schemas/Project.ts | 4 +- src/api/resolvers/Mutation.ts | 16 ++++++ .../type-defs/inputs/CreateImageTagInput.ts | 6 ++ .../type-defs/inputs/DeleteImageTagInput.ts | 6 ++ src/api/type-defs/objects/Image.ts | 1 + src/api/type-defs/objects/Project.ts | 2 +- .../type-defs/payloads/ImageTagsPayload.ts | 5 ++ src/api/type-defs/root/Mutation.ts | 3 + 12 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 src/api/type-defs/inputs/CreateImageTagInput.ts create mode 100644 src/api/type-defs/inputs/DeleteImageTagInput.ts create mode 100644 src/api/type-defs/payloads/ImageTagsPayload.ts diff --git a/src/@types/graphql.ts b/src/@types/graphql.ts index 1762b825..bc4238e4 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; @@ -261,6 +266,11 @@ export type DeleteImageCommentInput = { imageId: Scalars['ID']['input']; }; +export type DeleteImageTagInput = { + imageId: Scalars['ID']['input']; + tagId: Scalars['ID']['input']; +}; + export type DeleteImagesInput = { imageIds?: InputMaybe>; }; @@ -425,6 +435,7 @@ export type Image = { path?: Maybe; projectId: Scalars['String']['output']; reviewed?: Maybe; + tags?: Maybe>; timezone: Scalars['String']['output']; userSetData?: Maybe; }; @@ -492,6 +503,11 @@ export type ImageMetadata = { timezone?: Maybe; }; +export type ImageTagsPayload = { + __typename?: 'ImageTagsPayload'; + tags?: Maybe>; +}; + export type ImagesConnection = { __typename?: 'ImagesConnection'; images: Array; @@ -568,6 +584,7 @@ export type Mutation = { createImage?: Maybe; createImageComment?: Maybe; createImageError?: Maybe; + createImageTag?: Maybe; createInternalLabels?: Maybe; createLabels?: Maybe; createObjects?: Maybe; @@ -579,6 +596,7 @@ export type Mutation = { createView?: Maybe; deleteDeployment?: Maybe; deleteImageComment?: Maybe; + deleteImageTag?: Maybe; deleteImages?: Maybe; deleteLabels?: Maybe; deleteObjects?: Maybe; @@ -644,6 +662,11 @@ export type MutationCreateImageErrorArgs = { }; +export type MutationCreateImageTagArgs = { + input: CreateImageTagInput; +}; + + export type MutationCreateInternalLabelsArgs = { input: CreateInternalLabelsInput; }; @@ -699,6 +722,11 @@ export type MutationDeleteImageCommentArgs = { }; +export type MutationDeleteImageTagArgs = { + input: DeleteImageTagInput; +}; + + export type MutationDeleteImagesArgs = { input: DeleteImagesInput; }; @@ -904,7 +932,7 @@ export type ProjectRegistration = { export type ProjectTag = { __typename?: 'ProjectTag'; - _id: Scalars['String']['output']; + _id: Scalars['ID']['output']; color: Scalars['String']['output']; name: Scalars['String']['output']; }; 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 8f402177..f3714139 100644 --- a/src/api/db/models/Image.ts +++ b/src/api/db/models/Image.ts @@ -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,50 @@ 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((c) => idMatch(c._id!, input.tagId))[0]; + if (!tag) throw new NotFoundError('Tag not found on image'); + + image.tags = image.tags.filter( + (c) => !idMatch(c._id!, 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); + } + } + /** * Finds Image records and creates new Object subdocuments on them * It's used by frontend when creating new empty objects and when adding @@ -1113,6 +1158,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/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 e9659b97..af0e07d1 100644 --- a/src/api/db/schemas/Project.ts +++ b/src/api/db/schemas/Project.ts @@ -72,7 +72,7 @@ const ProjectLabelSchema = new Schema({ }); const ProjectTagSchema = new Schema({ - _id: { type: String, required: true, default: randomUUID }, + _id: { type: Schema.Types.ObjectId, required: true, auto: true }, name: { type: String, required: true }, color: { type: String, required: true } }); @@ -109,7 +109,7 @@ const ProjectSchema = new Schema({ ], required: true, }, - tags: { type: [ProjectTagSchema] } + tags: { type: [ProjectTagSchema] }, }); export default mongoose.model('Project', ProjectSchema); diff --git a/src/api/resolvers/Mutation.ts b/src/api/resolvers/Mutation.ts index a9ca3540..23e754aa 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, 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/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/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 4b46746f..bb9bf673 100644 --- a/src/api/type-defs/objects/Project.ts +++ b/src/api/type-defs/objects/Project.ts @@ -23,7 +23,7 @@ export default /* GraphQL */ ` } type ProjectTag { - _id: String! + _id: ID! name: String! color: 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/root/Mutation.ts b/src/api/type-defs/root/Mutation.ts index 6f44be93..50e3bcfc 100644 --- a/src/api/type-defs/root/Mutation.ts +++ b/src/api/type-defs/root/Mutation.ts @@ -28,6 +28,9 @@ export default /* GraphQL */ ` 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 From 97cf1219b854608685aa47aeebf0026e64257b6b Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Fri, 1 Nov 2024 14:21:15 -0400 Subject: [PATCH 07/10] fix: fix filter --- src/api/db/models/Image.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/db/models/Image.ts b/src/api/db/models/Image.ts index f3714139..f9bbc648 100644 --- a/src/api/db/models/Image.ts +++ b/src/api/db/models/Image.ts @@ -504,11 +504,11 @@ export class ImageModel { try { const image = await ImageModel.queryById(input.imageId, context); - const tag = image.tags?.filter((c) => idMatch(c._id!, input.tagId))[0]; + 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( - (c) => !idMatch(c._id!, input.tagId), + (t) => !idMatch(t, input.tagId), ) as mongoose.Types.ObjectId[]; await image.save(); From 8bc0d2c0c961ca9548bec3db7a509829dff9e0a0 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Fri, 1 Nov 2024 20:56:58 -0400 Subject: [PATCH 08/10] feat(138): implement cascading delete --- src/api/db/models/Image.ts | 21 ++++++++++++++++++++- src/api/db/models/Project.ts | 3 +-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/api/db/models/Image.ts b/src/api/db/models/Image.ts index f9bbc648..871a4221 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'; @@ -520,6 +520,25 @@ export class ImageModel { } } + static async deleteProjectTag( + input: { tagId: string }, + context: Pick, + ): Promise { + try { + const res = await Image.updateMany({ + "tags": input.tagId + }, { + "$pull": { + "tags": 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 diff --git a/src/api/db/models/Project.ts b/src/api/db/models/Project.ts index 8fdd3bcf..4538b952 100644 --- a/src/api/db/models/Project.ts +++ b/src/api/db/models/Project.ts @@ -637,8 +637,7 @@ export class ProjectModel { throw new DeleteTagError('Tag not found on project'); } - // TODO implement pipeline - // TODO implement delete from existing images + await ImageModel.deleteProjectTag({ tagId: input._id }, context); project.tags.splice(project.tags.indexOf(tag), 1); From cc107ccd6a01c997cdf1377e4dc3f399f451cb70 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Mon, 25 Nov 2024 23:31:56 -0500 Subject: [PATCH 09/10] fix: fix bulk delete of project tag from iamges --- src/api/db/models/Image.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/api/db/models/Image.ts b/src/api/db/models/Image.ts index 788b835d..cea8dda6 100644 --- a/src/api/db/models/Image.ts +++ b/src/api/db/models/Image.ts @@ -525,12 +525,11 @@ export class ImageModel { context: Pick, ): Promise { try { - const res = await Image.updateMany({ - "tags": input.tagId - }, { - "$pull": { - "tags": input.tagId - } + 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) { From 21d51c716cd1de1948e52ad24984316e11b16a27 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Sat, 30 Nov 2024 21:22:35 -0500 Subject: [PATCH 10/10] feat(138): add max for number of images that can be modified when removing project tag --- src/api/db/models/Image.ts | 18 ++++++++++++++++++ src/api/db/models/Project.ts | 11 ++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/api/db/models/Image.ts b/src/api/db/models/Image.ts index cea8dda6..e2343190 100644 --- a/src/api/db/models/Image.ts +++ b/src/api/db/models/Image.ts @@ -520,6 +520,24 @@ export class ImageModel { } } + 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, diff --git a/src/api/db/models/Project.ts b/src/api/db/models/Project.ts index 4538b952..7e8897ab 100644 --- a/src/api/db/models/Project.ts +++ b/src/api/db/models/Project.ts @@ -41,7 +41,7 @@ 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 = 500; +const MAX_TAG_DELETE = 50000; const ObjectId = mongoose.Types.ObjectId; @@ -637,6 +637,15 @@ export class ProjectModel { 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);