diff --git a/.gitignore b/.gitignore index 11f3dea9..17550172 100644 --- a/.gitignore +++ b/.gitignore @@ -108,9 +108,10 @@ build tmp .env.local -# Intellij +# Intellij and VSCode .idea/ *.iml +.vscode/settings.json /export/ -/openbeta-export/ \ No newline at end of file +/openbeta-export/ diff --git a/src/db/ClimbSchema.ts b/src/db/ClimbSchema.ts index 1221a843..641efa09 100644 --- a/src/db/ClimbSchema.ts +++ b/src/db/ClimbSchema.ts @@ -59,6 +59,28 @@ const MetadataSchema = new Schema({ } }, { _id: false }) +const PitchSchema = new mongoose.Schema({ + _id: { + type: 'object', + value: { type: 'Buffer' }, + default: () => muuid.v4() + }, + uuid: { + type: 'string', + default: function () { return this._id.toString() } + }, + parentId: { type: String, required: true }, + number: { type: Number, required: true }, + grades: { type: mongoose.Schema.Types.Mixed }, + type: { type: mongoose.Schema.Types.Mixed }, + length: { type: Number }, + boltsCount: { type: Number }, + description: { type: String } +}, { + _id: true, + timestamps: true +}) + const GradeTypeSchema = new Schema({ vscale: Schema.Types.String, yds: { type: Schema.Types.String, required: false }, @@ -86,6 +108,7 @@ export const ClimbSchema = new Schema({ required: true }, boltsCount: { type: Schema.Types.Number, required: false }, + pitches: { type: [PitchSchema], default: undefined, required: false }, metadata: MetadataSchema, content: ContentSchema, _deleting: { type: Date }, diff --git a/src/db/ClimbTypes.ts b/src/db/ClimbTypes.ts index ab8bd273..da68e3a2 100644 --- a/src/db/ClimbTypes.ts +++ b/src/db/ClimbTypes.ts @@ -27,21 +27,35 @@ export type ClimbGQLQueryType = ClimbType & { * Clinbs have a number of fields that may be expected to appear within their documents. */ export type ClimbType = IClimbProps & { + pitches?: IPitch[] metadata: IClimbMetadata content?: IClimbContent } +/* Models a single pitch of a multi-pitch route */ +export interface IPitch { + _id: MUUID + parentId: string + number: number + grades?: Partial> + type?: DisciplineType + length?: number + boltsCount?: number + description?: string +} + export interface IClimbProps { _id: MUUID name: string /** First ascent, if known. Who was the first person to climb this route? */ fa?: string yds?: string - - /** Total length in metersif known. We will support individual pitch lenth in the future. */ + /** Total length in meters, if known */ length?: number /** Total number of bolts (fixed anchors) */ boltsCount?: number + /* Array of Pitch objects representing the individual pitches of the climb */ + pitches?: IPitch[] | undefined /** * Grades appear within as an I18n-safe format. * We achieve this via a larger data encapsulation, and perform interpretation and comparison @@ -127,8 +141,8 @@ export interface IClimbMetadata { /** mountainProject ID (if this climb was sourced from mountainproject) */ mp_id?: string /** - * If this climb was sourced from mountianproject, we expect a parent ID - * for its crag to also be Available + * If this climb was sourced from mountainproject, we expect a parent ID + * for its crag to also be available */ mp_crag_id?: string /** the parent Area in which this climb appears */ @@ -150,6 +164,17 @@ export interface IClimbContent { export type ClimbGradeContextType = Record +export interface PitchChangeInputType { + id?: string + parentId?: string + number?: number + grades?: Partial> + type?: DisciplineType + length?: number + boltsCount?: number + description?: string +} + export interface ClimbChangeInputType { id?: string name?: string @@ -160,6 +185,7 @@ export interface ClimbChangeInputType { location?: string protection?: string boltsCount?: number + pitches?: PitchChangeInputType[] | undefined fa?: string length?: number experimentalAuthor?: { @@ -168,7 +194,8 @@ export interface ClimbChangeInputType { } } -type UpdatableClimbFieldsType = Pick +type UpdatableClimbFieldsType = Pick + /** * Minimum required fields when adding a new climb or boulder problem */ diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index a3b00016..dfb4f817 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -153,6 +153,18 @@ const resolvers = { boltsCount: (node: ClimbGQLQueryType) => node.boltsCount ?? -1, + pitches: (node: ClimbGQLQueryType) => node.pitches != null + ? node.pitches.map(pitch => { + const { parentId, ...otherPitchProps } = pitch + return { + id: pitch._id?.toUUID().toString(), + uuid: pitch._id?.toUUID().toString(), + parentId: node._id?.toUUID().toString(), + ...otherPitchProps + } + }) + : null, + grades: (node: ClimbGQLQueryType) => node.grades ?? null, metadata: (node: ClimbGQLQueryType) => { diff --git a/src/graphql/schema/Climb.gql b/src/graphql/schema/Climb.gql index 9e8bdc9d..f7cca045 100644 --- a/src/graphql/schema/Climb.gql +++ b/src/graphql/schema/Climb.gql @@ -20,7 +20,10 @@ type Climb { length: Int! "Number of bolts/permanent anchors, if known (-1 otherwise)" - boltsCount: Int! + boltsCount: Int + + "List of Pitch objects representing individual pitches of a multi-pitch climb" + pitches: [Pitch] "The grade(s) assigned to this climb. See GradeType documentation" grades: GradeType @@ -191,3 +194,15 @@ enum SafetyEnum { "No protection and overall the route is extremely dangerous." X } + +type Pitch { + id: ID! + uuid: ID! + parentId: ID! + number: Int! + grades: GradeType + type: ClimbType + length: Int + boltsCount: Int + description: String +} \ No newline at end of file diff --git a/src/model/MutableClimbDataSource.ts b/src/model/MutableClimbDataSource.ts index f3a75f6a..36df25e6 100644 --- a/src/model/MutableClimbDataSource.ts +++ b/src/model/MutableClimbDataSource.ts @@ -2,7 +2,7 @@ import muid, { MUUID } from 'uuid-mongodb' import { UserInputError } from 'apollo-server' import { ClientSession } from 'mongoose' -import { ClimbChangeDocType, ClimbChangeInputType, ClimbEditOperationType } from '../db/ClimbTypes.js' +import { ClimbChangeDocType, ClimbChangeInputType, ClimbEditOperationType, IPitch } from '../db/ClimbTypes.js' import ClimbDataSource from './ClimbDataSource.js' import { createInstance as createExperimentalUserDataSource } from './ExperimentalUserDataSource.js' import { sanitizeDisciplines, gradeContextToGradeScales, createGradeObject } from '../GradeUtils.js' @@ -107,7 +107,7 @@ export default class MutableClimbDataSource extends ClimbDataSource { for (let i = 0; i < userInput.length; i++) { // when adding new climbs we require name and disciplines if (!idList[i].existed && userInput[i].name == null) { - throw new UserInputError(`Can't add new climbs without name. (Index[index=${i}])`) + throw new UserInputError(`Can't add new climbs without name. (Index[index=${i}])`) } // See https://github.com/OpenBeta/openbeta-graphql/issues/244 @@ -125,6 +125,23 @@ export default class MutableClimbDataSource extends ClimbDataSource { ? createGradeObject(grade, typeSafeDisciplines, cragGradeScales) : null + const pitches = userInput[i].pitches + + const newPitchesWithIDs = pitches != null + ? pitches.map((pitch): IPitch => { + if (pitch.number === undefined) { + throw new UserInputError('Each pitch in a multi-pitch climb must have a number representing its sequence in the climb. Please ensure that every pitch is numbered.') + } + + return { + ...pitch, + _id: muid.from(pitch.id ?? muid.v4()), // generate MUUID if not present + parentId: muid.from(pitch.parentId ?? newClimbIds[i]).toString(), + number: pitch.number + } + }) + : null + const { description, location, protection, name, fa, length, boltsCount } = userInput[i] // Make sure we don't update content = {} @@ -151,7 +168,8 @@ export default class MutableClimbDataSource extends ClimbDataSource { gradeContext: parent.gradeContext, ...fa != null && { fa }, ...length != null && length > 0 && { length }, - ...boltsCount != null && boltsCount > 0 && { boltsCount }, + ...boltsCount != null && boltsCount >= 0 && { boltsCount }, // Include 'boltsCount' if it's defined and its value is 0 (no bolts) or greater + ...newPitchesWithIDs != null && { pitches: newPitchesWithIDs }, ...Object.keys(content).length > 0 && { content }, metadata: { areaRef: parent.metadata.area_id, diff --git a/src/model/__tests__/MutableClimbDataSource.ts b/src/model/__tests__/MutableClimbDataSource.ts index eef439cd..9731c784 100644 --- a/src/model/__tests__/MutableClimbDataSource.ts +++ b/src/model/__tests__/MutableClimbDataSource.ts @@ -109,6 +109,36 @@ describe('Climb CRUD', () => { grade: 'WI8+' } + // Define a sport climb with two individual pitches + const newClimbWithPitches: ClimbChangeInputType = { + name: 'Short Multi-Pitch', + disciplines: { + sport: true + }, + grade: '7', // max grade of its child pitches + description: 'A challenging climb with two pitches', + location: '5m left of the big tree', + protection: '5 quickdraws', + pitches: [ + { + number: 1, + grades: { uiaa: '7' }, + type: { sport: true }, + length: 30, + boltsCount: 5, + description: 'First pitch description' + }, + { + number: 2, + grades: { uiaa: '6+' }, + type: { sport: true }, + length: 40, + boltsCount: 6, + description: 'Second pitch description' + } + ] + } + beforeAll(async () => { await connectDB() stream = await streamListener() @@ -473,4 +503,141 @@ describe('Climb CRUD', () => { boltsCount: change.boltsCount }) }) + + it('can add multi-pitch climbs', async () => { + await areas.addCountry('aut') + + const newDestination = await areas.addArea(testUser, 'Some Location with Multi-Pitch Climbs', null, 'aut') + if (newDestination == null) fail('Expect new area to be created') + + const routesArea = await areas.addArea(testUser, 'Sport & Trad Multi-Pitches', newDestination.metadata.area_id) + + // create new climb with individual pitches + const newIDs = await climbs.addOrUpdateClimbs( + testUser, + routesArea.metadata.area_id, + [newClimbWithPitches] + ) + + expect(newIDs).toHaveLength(1) + + const climb = await climbs.findOneClimbByMUUID(muid.from(newIDs[0])) + + // Validate new climb + expect(climb).toMatchObject({ + name: newClimbWithPitches.name, + type: sanitizeDisciplines(newClimbWithPitches.disciplines), + content: { + description: newClimbWithPitches.description, + location: newClimbWithPitches.location, + protection: newClimbWithPitches.protection + }, + pitches: newClimbWithPitches.pitches + }) + // Validate each pitch + if (climb?.pitches != null) { + climb.pitches.forEach((pitch) => { + expect(pitch).toHaveProperty('_id') + expect(pitch).toHaveProperty('parentId') + expect(pitch).toHaveProperty('number') + }) + } else { + fail('Pitches are missing either of required attributes id, parentId, number') + } + }) + + it('can update multi-pitch problems', async () => { + const newDestination = await areas.addArea(testUser, 'Some Multi-Pitch Area to be Updated', null, 'deu') + + if (newDestination == null) fail('Expect new area to be created') + + const newIDs = await climbs.addOrUpdateClimbs( + testUser, + newDestination.metadata.area_id, + [newClimbWithPitches] + ) + + // Fetch the original climb + const original = await climbs.findOneClimbByMUUID(muid.from(newIDs[0])) + + // Check if 'original' is not null before accessing its properties + if ((original == null) || (original.pitches == null) || original.pitches.length < 2) { + fail('Original climb is null or does not have at least two pitches (as defined in the test case)') + return + } + + // Store original pitch IDs and parent IDs + const originalPitch1ID = original.pitches[0]._id.toUUID().toString() + const originalPitch2ID = original.pitches[1]._id.toUUID().toString() + const originalPitch1ParentID = original.pitches[0].parentId + const originalPitch2ParentID = original.pitches[1].parentId + + // Define updated pitch info + const updatedPitch1 = { + id: originalPitch1ID, + parentId: originalPitch1ParentID, + number: 1, + grades: { ewbank: '19' }, + type: { sport: false, alpine: true }, + length: 20, + boltsCount: 6, + description: 'Updated first pitch description' + } + + const updatedPitch2 = { + id: originalPitch2ID, + parentId: originalPitch2ParentID, + number: 2, + grades: { ewbank: '18' }, + type: { sport: false, alpine: true }, + length: 25, + boltsCount: 5, + description: 'Updated second pitch description' + } + + const changes: ClimbChangeInputType[] = [ + { + id: newIDs[0], + pitches: [updatedPitch1, updatedPitch2] + } + ] + + // update climb + await climbs.addOrUpdateClimbs(testUser, newDestination.metadata.area_id, changes) + + // Fetch the updated climb + const updatedClimb = await climbs.findOneClimbByMUUID(muid.from(newIDs[0])) + + if (updatedClimb != null) { + // Check that the pitches.id and pitches.parentId are identical to the original values + if (updatedClimb.pitches != null) { + const assertPitch = ( + pitch, + expectedPitch, + originalID, + originalParentID + ): void => { + expect(pitch._id.toUUID().toString()).toEqual(originalID) + expect(pitch.parentId).toEqual(originalParentID) + expect(pitch.number).toEqual(expectedPitch.number) + expect(pitch.grades).toEqual(expectedPitch.grades) + expect(pitch.type).toEqual(expectedPitch.type) + expect(pitch.length).toEqual(expectedPitch.length) + expect(pitch.boltsCount).toEqual(expectedPitch.boltsCount) + expect(pitch.description).toEqual(expectedPitch.description) + } + + assertPitch(updatedClimb.pitches[0], updatedPitch1, originalPitch1ID, originalPitch1ParentID) + assertPitch(updatedClimb.pitches[1], updatedPitch2, originalPitch2ID, originalPitch2ParentID) + } + + // Check that the createdBy and updatedBy fields are not undefined before accessing their properties + if ((updatedClimb.createdBy != null) && (updatedClimb.updatedBy != null)) { + expect(updatedClimb.createdBy.toUUID().toString()).toEqual(testUser.toString()) + expect(updatedClimb.updatedBy.toUUID().toString()).toEqual(testUser.toString()) + } else { + fail('createdBy or updatedBy is undefined') + } + } + }) })