From 98d54fc1767e773df6bce1e8139697bfc7dd9d9f Mon Sep 17 00:00:00 2001 From: Viet Nguyen <3805254+vnugent@users.noreply.github.com> Date: Fri, 3 May 2024 13:49:59 -0700 Subject: [PATCH] feat: support adding/updating topo data (#401) --- package.json | 1 + src/db/MediaObjectSchema.ts | 3 +- src/db/MediaObjectTypes.ts | 4 +- src/graphql/media/MediaResolvers.ts | 3 +- src/graphql/media/mutations.ts | 12 ++++-- src/graphql/resolvers.ts | 2 + src/graphql/schema/Media.gql | 10 ++++- src/model/MutableMediaDataSource.ts | 52 +++++++++++++++++--------- src/model/__tests__/MediaDataSource.ts | 18 ++++----- yarn.lock | 5 +++ 10 files changed, 76 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 8955c7a8..fdd1ee28 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "graphql": "^16.8.1", "graphql-middleware": "^6.1.31", "graphql-shield": "^7.5.0", + "graphql-type-json": "^0.3.2", "i18n-iso-countries": "^7.5.0", "immer": "^9.0.15", "jsonwebtoken": "^8.5.1", diff --git a/src/db/MediaObjectSchema.ts b/src/db/MediaObjectSchema.ts index 3176e3cf..e1ef494f 100644 --- a/src/db/MediaObjectSchema.ts +++ b/src/db/MediaObjectSchema.ts @@ -19,7 +19,8 @@ const EntitySchema = new Schema({ type: PointSchema, index: '2dsphere', required: false - } + }, + topoData: { type: Schema.Types.Mixed } }, { _id: true, toObject: { versionKey: false } }) const schema = new Schema({ diff --git a/src/db/MediaObjectTypes.ts b/src/db/MediaObjectTypes.ts index b668bbfd..65f34deb 100644 --- a/src/db/MediaObjectTypes.ts +++ b/src/db/MediaObjectTypes.ts @@ -24,6 +24,7 @@ export interface EntityTag { climbName?: string areaName: string lnglat?: Point + topoData?: object } export interface MediaByUsers { @@ -99,12 +100,13 @@ export interface AddEntityTagGQLInput { mediaId: string entityId: string entityType: number + topoData?: object } /** * Formal input type for addEntityTag function */ -export type AddTagEntityInput = Pick & { +export type AddTagEntityInput = Pick & { mediaId: mongoose.Types.ObjectId entityUuid: MUUID } diff --git a/src/graphql/media/MediaResolvers.ts b/src/graphql/media/MediaResolvers.ts index c2f16837..8e031ab0 100644 --- a/src/graphql/media/MediaResolvers.ts +++ b/src/graphql/media/MediaResolvers.ts @@ -28,7 +28,8 @@ const MediaResolvers = { id: (node: EntityTag) => node._id, targetId: (node: EntityTag) => node.targetId.toUUID().toString(), lat: (node: EntityTag) => geojsonPointToLatitude(node.lnglat), - lng: (node: EntityTag) => geojsonPointToLongitude(node.lnglat) + lng: (node: EntityTag) => geojsonPointToLongitude(node.lnglat), + topoData: (node: EntityTag) => node?.topoData }, DeleteTagResult: { diff --git a/src/graphql/media/mutations.ts b/src/graphql/media/mutations.ts index b180e371..6bbacdbd 100644 --- a/src/graphql/media/mutations.ts +++ b/src/graphql/media/mutations.ts @@ -19,11 +19,12 @@ const MediaMutations = { addEntityTag: async (_: any, args, { dataSources }: Context): Promise => { const { media } = dataSources const { input }: { input: AddEntityTagGQLInput } = args - const { mediaId, entityId, entityType } = input - return await media.addEntityTag({ + const { mediaId, entityId, entityType, topoData } = input + return await media.upsertEntityTag({ mediaId: new mongoose.Types.ObjectId(mediaId), entityUuid: muid.from(entityId), - entityType + entityType, + topoData }) }, @@ -36,6 +37,11 @@ const MediaMutations = { tagId: new mongoose.Types.ObjectId(tagId) }) } + + // updateTopoData: async (_: any, args, { dataSources }: Context): Promise => { + // const { media } = dataSources + // const { input }: { input: AddEntityTagGQLInput } = args + // const { mediaId, entityId, entityType } export default MediaMutations diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 094fafcb..3eab464e 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -3,6 +3,7 @@ import muid, { MUUID } from 'uuid-mongodb' import fs from 'fs' import { gql } from 'apollo-server-express' import { DocumentNode } from 'graphql' +import { GraphQLJSONObject } from 'graphql-type-json' import { CommonResolvers, CommonTypeDef } from './common/index.js' import { HistoryFieldResolvers, HistoryQueries } from '../graphql/history/index.js' @@ -128,6 +129,7 @@ const resolvers = { ...PostResolvers, ...XMediaResolvers, ...UserResolvers, + JSONObject: GraphQLJSONObject, Climb: { id: (node: ClimbGQLQueryType) => node._id.toUUID().toString(), diff --git a/src/graphql/schema/Media.gql b/src/graphql/schema/Media.gql index fc4dd58c..78119a5b 100644 --- a/src/graphql/schema/Media.gql +++ b/src/graphql/schema/Media.gql @@ -1,3 +1,5 @@ +scalar JSONObject + type Mutation { """ Add one or more media objects. Each media object may contain one tag. @@ -11,7 +13,8 @@ type Mutation { """ - Add an entity tag to a media. + Add an entity tag to a media. Calling this function with the same + mediaId, entityUuid, and entityType will update the topo data. """ addEntityTag(input: MediaEntityTagInput): EntityTag! @@ -150,6 +153,9 @@ type EntityTag { "Latitude" lat: Float! + + "Topo data" + topoData: JSONObject } "Represent a media object" @@ -195,6 +201,8 @@ input MediaEntityTagInput { entityId: ID! "0: climb, 1: area" entityType: Int! + "Optional topo data" + topoData: JSONObject } "Input parameters for deleting a tag" diff --git a/src/model/MutableMediaDataSource.ts b/src/model/MutableMediaDataSource.ts index 531dbb58..011420fd 100644 --- a/src/model/MutableMediaDataSource.ts +++ b/src/model/MutableMediaDataSource.ts @@ -61,30 +61,46 @@ export default class MutableMediaDataSource extends MediaDataSource { } /** - * Add a new entity tag (a climb or area) to a media object. - * @returns new EntityTag . 'null' if the entity already exists. + * Add a new entity tag to a media object. `mediaId`, `entityUuid`, `entityType` + * together uniquely identify the entity tag. Providing the same 3 IDs with a + * different `topoData` to update the existing entity tag. + * @returns the new EntityTag or the one being updated. */ - async addEntityTag ({ mediaId, entityUuid, entityType }: AddTagEntityInput): Promise { + async upsertEntityTag ({ mediaId, entityUuid, entityType, topoData }: AddTagEntityInput): Promise { // Find the entity we want to tag const newEntityTagDoc = await this.getEntityDoc({ entityUuid, entityType }) - - // We treat 'entityTags' like a Set - can't tag the same climb/area id twice. - // See https://stackoverflow.com/questions/33576223/using-mongoose-mongodb-addtoset-functionality-on-array-of-objects - const filter = { - _id: new mongoose.Types.ObjectId(mediaId), - 'entityTags.targetId': { $ne: entityUuid } - } - - await this.mediaObjectModel - .updateOne( - filter, - { + newEntityTagDoc.topoData = topoData + + // Use `bulkWrite` because we can't upsert an array element in a document. + // See https://www.mongodb.com/community/forums/t/how-to-update-nested-array-using-arrayfilters-but-if-it-doesnt-find-a-match-it-should-insert-new-values/245505 + const bulkOperations: any [] = [{ + updateOne: { + filter: { + _id: new mongoose.Types.ObjectId(mediaId) + }, + update: { + $pull: { + entityTags: { targetId: entityUuid } + } + } + } + }, { + // We treat 'entityTags' like a Set - can't add a new tag the same climb/area id twice. + // See https://stackoverflow.com/questions/33576223/using-mongoose-mongodb-addtoset-functionality-on-array-of-objects + updateOne: { + filter: { + _id: new mongoose.Types.ObjectId(mediaId), + 'entityTags.targetId': { $ne: entityUuid } + }, + update: { $push: { entityTags: newEntityTagDoc } - }) - .orFail(new UserInputError('Media not found or tag already exists.')) - .lean() + } + } + }] + + await this.mediaObjectModel.bulkWrite(bulkOperations, { ordered: true }) return newEntityTagDoc } diff --git a/src/model/__tests__/MediaDataSource.ts b/src/model/__tests__/MediaDataSource.ts index afe34892..0a30a164 100644 --- a/src/model/__tests__/MediaDataSource.ts +++ b/src/model/__tests__/MediaDataSource.ts @@ -82,7 +82,8 @@ describe('MediaDataSource', () => { areaTag2 = { mediaId: testMediaObject._id, entityType: 1, - entityUuid: areaForTagging2.metadata.area_id + entityUuid: areaForTagging2.metadata.area_id, + topoData: { name: 'AA', value: '1234' } } climbTag = { @@ -102,7 +103,7 @@ describe('MediaDataSource', () => { entityType: 1, entityUuid: muuid.v4() // some random area } - await expect(media.addEntityTag(badAreaTag)).rejects.toThrow(/area .* not found/i) + await expect(media.upsertEntityTag(badAreaTag)).rejects.toThrow(/area .* not found/i) }) it('should not tag a nonexistent *climb*', async () => { @@ -111,7 +112,7 @@ describe('MediaDataSource', () => { entityType: 0, entityUuid: muuid.v4() // some random climb } - await expect(media.addEntityTag(badClimbTag)).rejects.toThrow(/climb .* not found/i) + await expect(media.upsertEntityTag(badClimbTag)).rejects.toThrow(/climb .* not found/i) }) it('should tag & remove an area tag', async () => { @@ -122,10 +123,10 @@ describe('MediaDataSource', () => { expect(mediaObjects[0].entityTags).toHaveLength(0) // add 1st tag - await media.addEntityTag(areaTag1) + await media.upsertEntityTag(areaTag1) // add 2nd tag - const tag = await media.addEntityTag(climbTag) + const tag = await media.upsertEntityTag(climbTag) expect(tag).toMatchObject>({ targetId: climbTag.entityUuid, @@ -165,11 +166,10 @@ describe('MediaDataSource', () => { }) it('should not add a duplicate tag', async () => { - const newTag = await media.addEntityTag(areaTag2) + const updating = { ...areaTag2, topoData: { name: 'ZZ' } } + const newTag = await media.upsertEntityTag(updating) expect(newTag.targetId).toEqual(areaTag2.entityUuid) - - // Insert the same tag again - await expect(media.addEntityTag(areaTag2)).rejects.toThrowError(/tag already exists/i) + expect(newTag.topoData).toEqual(updating.topoData) }) it('should not add media with the same url', async () => { diff --git a/yarn.lock b/yarn.lock index 53c674f2..88f4256b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4362,6 +4362,11 @@ graphql-tag@^2.11.0: dependencies: tslib "^2.1.0" +graphql-type-json@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.3.2.tgz#f53a851dbfe07bd1c8157d24150064baab41e115" + integrity sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg== + graphql@^16.8.1: version "16.8.1" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"