From b1f8288efd1898ae95b8b96fc81911dfe9f9d73d Mon Sep 17 00:00:00 2001 From: prithipal khalsa Date: Wed, 22 Jan 2025 15:48:55 -0700 Subject: [PATCH 1/7] tick type enum and validation --- src/__tests__/ticks.ts | 40 ++++++++++++++++++++++++-- src/db/TickSchema.ts | 4 +-- src/db/TickTypes.ts | 9 ++++-- src/graphql/schema/Tick.gql | 42 ++++++++++++++++++++++++--- src/model/TickDataSource.ts | 27 +++++++++++++++++- src/model/__tests__/ticks.ts | 55 ++++++++++++++++++++++++++++++++---- 6 files changed, 160 insertions(+), 17 deletions(-) diff --git a/src/__tests__/ticks.ts b/src/__tests__/ticks.ts index 33707330..e038b642 100644 --- a/src/__tests__/ticks.ts +++ b/src/__tests__/ticks.ts @@ -9,9 +9,30 @@ import UserDataSource from '../model/UserDataSource.js' import { UpdateProfileGQLInput } from '../db/UserTypes.js' import { InMemoryDB } from '../utils/inMemoryDB.js' import express from 'express' +import MutableClimbDataSource from '../model/MutableClimbDataSource.js' +import MutableAreaDataSource from '../model/MutableAreaDataSource.js' +import { ClimbChangeInputType } from '../db/ClimbTypes.js' jest.setTimeout(110000) +const newClimbsToAdd: ClimbChangeInputType[] = [ + { + name: 'Sport 1', + disciplines: { + sport: true + }, + description: 'The best climb', + location: '5m left of the big tree', + protection: '5 quickdraws' + }, + { + name: 'Deep water 1', + disciplines: { + deepwatersolo: true + } + } +] + describe('ticks API', () => { let server: ApolloServer let user: muuid.MUUID @@ -23,6 +44,8 @@ describe('ticks API', () => { let ticks: TickDataSource let users: UserDataSource let tickOne: TickInput + let climbs: MutableClimbDataSource + let areas: MutableAreaDataSource beforeAll(async () => { ({ server, inMemoryDB, app } = await setUpServer()) @@ -32,7 +55,7 @@ describe('ticks API', () => { tickOne = { name: 'Route One', notes: 'Nice slab', - climbId: 'c76d2083-6b8f-524a-8fb8-76e1dc79833f', + climbId: 'tbd', // need to create a climb for tick validation userId: userUuid, style: 'Lead', attemptType: 'Onsight', @@ -45,7 +68,19 @@ describe('ticks API', () => { beforeEach(async () => { ticks = TickDataSource.getInstance() users = UserDataSource.getInstance() + climbs = MutableClimbDataSource.getInstance() + areas = MutableAreaDataSource.getInstance() await inMemoryDB.clear() + + // Add climbs because add/update tick requires type validation + await areas.addCountry('usa') + const newDestination = await areas.addArea(user, 'California', null, 'usa') + const routesArea = await areas.addArea(user, 'Sport & Trad', newDestination.metadata.area_id) + + const newIDs = await climbs.addOrUpdateClimbs(user, routesArea.metadata.area_id, newClimbsToAdd) + // Update tick inputs with generated climb IDs + tickOne.climbId = newIDs[0] + console.log('newIDs[0] ', newIDs[0]) }) afterAll(async () => { @@ -91,6 +126,7 @@ describe('ticks API', () => { username: 'cat.dog', email: 'cat@example.com' } + console.log('tickOne ', tickOne) await users.createOrUpdateUserProfile(user, userProfileInput) await ticks.addTick(tickOne) const response = await queryAPI({ @@ -202,7 +238,7 @@ describe('ticks API', () => { _id: createTickRes._id, updatedTick: { name: 'Updated Route One', - climbId: 'new climb id', + climbId: tickOne.climbId, userId: userUuid, dateClimbed: new Date('2022-11-10T12:00:00Z'), grade: 'new grade', diff --git a/src/db/TickSchema.ts b/src/db/TickSchema.ts index 53e9a861..e450b4d1 100644 --- a/src/db/TickSchema.ts +++ b/src/db/TickSchema.ts @@ -16,8 +16,8 @@ export const TickSchema = new Schema({ notes: { type: Schema.Types.String, required: false }, climbId: { type: Schema.Types.String, required: true, index: true }, userId: { type: Schema.Types.String, required: true, index: true }, - style: { type: Schema.Types.String, required: true, default: '' }, - attemptType: { type: Schema.Types.String, required: true, index: true, default: '' }, + style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow'], required: false }, + attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Frenchfree', 'Fell/Hung', 'Send', 'Attempt'], required: false, index: true }, dateClimbed: { type: Schema.Types.Date }, grade: { type: Schema.Types.String, required: true, index: true }, // Bear in mind that these enum types must be kept in sync with the TickSource enum diff --git a/src/db/TickTypes.ts b/src/db/TickTypes.ts index f2eb37ab..14451737 100644 --- a/src/db/TickTypes.ts +++ b/src/db/TickTypes.ts @@ -16,7 +16,12 @@ export type TickSource = */ 'MP' +export type TickStyle = 'Lead' | 'Solo' | 'TR' | 'Follow' +export type TickAttemptType = 'Onsight' | 'Flash' | 'Redpoint' | 'Pinkpoint' | 'Frenchfree' | 'Fell/Hung' | 'Send' | 'Attempt' + export const TickSourceValues: TickSource[] = ['OB', 'MP'] +export const TickStyleValues: TickStyle[] = ['Lead', 'Solo', 'TR', 'Follow'] +export const TickAttemptTypeValues: TickAttemptType[] = ['Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Frenchfree', 'Fell/Hung', 'Send', 'Attempt'] /** Ticks * Ticks represent log entries for a user's climbing activity. They contain @@ -66,7 +71,7 @@ export interface TickInput { * If this is a native tick, you can enforce updated values here by referencing * the climb document (climbId -> climbs:uuid) */ - style: string + style: TickStyle /** * Describe the type of successful attempt that was made here. @@ -74,7 +79,7 @@ export interface TickInput { * This is again a free-form field. Data of practically any descriptive nature may find * itself here. */ - attemptType: string + attemptType: TickAttemptType /** * Not the same as date created. Ticks can be back-filled by the user, and do diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index d52d9af9..0fdd9ffa 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -81,14 +81,14 @@ type TickType { If this is a native tick, you can enforce updated values here by referencing the climb document (climbId -> climbs:uuid) """ - style: String + style: TickStyle """ Describe the type of successful attempt that was made here. Fell/Hung, Flash, Redpoint, Onsight, would be examples of values you might find here. This is again a free-form field. Data of practically any descriptive nature may find itself here. """ - attemptType: String + attemptType: TickAttemptType """ Not the same as date created. Ticks can be back-filled by the user, and do not need to be logged at the time that the tick is created inside the mongo @@ -120,6 +120,40 @@ enum TickSource { MP } +"""The type of attempt the user wants to tick for a route.""" +enum TickAttemptType { + "Onsight: Sending a route fist try with no prior knowledge" + Onsight + "Flash: Sending a route first try with prior knowledge" + Flash + "Redpoint: Sending a route with no falls" + Redpoint + "Pinkpoint: Sending a route with no falls on pre-placed gear" + Pinkpoint + "French Free: Doing the route with pulling on gear or draws" + French_Free + "Fell/Hung: Falling or hanging on gear" + Fell_Hung + "Attempt" + Attempt + "Send" + Send + +} + +enum TickStyle { + "Lead" + Lead + "Solo" + Solo + "tr" + TR + "Follow" + Follow +} + + + """ This is our tick type input, containing the name, notes climbId, etc of the ticked climb, all fields are required @@ -131,8 +165,8 @@ input Tick { notes: String climbId: String! userId: String! - style: String - attemptType: String + style: TickStyle + attemptType: TickAttemptType dateClimbed: Date! grade: String! source: TickSource! diff --git a/src/model/TickDataSource.ts b/src/model/TickDataSource.ts index e380be94..11aaeb87 100644 --- a/src/model/TickDataSource.ts +++ b/src/model/TickDataSource.ts @@ -1,20 +1,23 @@ import { MongoDataSource } from 'apollo-datasource-mongodb' import type { DeleteResult } from 'mongodb' import mongoose from 'mongoose' +import muuid from 'uuid-mongodb' import { TickEditFilterType, TickInput, TickType, TickUserSelectors } from '../db/TickTypes' import { getTickModel, getUserModel } from '../db/index.js' import type { User } from '../db/UserTypes' +import { getClimbModel } from '../db/ClimbSchema.js' export default class TickDataSource extends MongoDataSource { tickModel = getTickModel() userModel = getUserModel() - + climbModel = getClimbModel() /** * @param tick takes in a new tick * @returns new tick */ async addTick (tick: TickInput): Promise { + await this.validateTick(tick) return await this.tickModel.create({ ...tick }) } @@ -57,10 +60,32 @@ export default class TickDataSource extends MongoDataSource { * @returns the new/updated tick */ async editTick (filter: TickEditFilterType, updatedTick: TickInput): Promise { + await this.validateTick(updatedTick) const rs = await this.tickModel.findOneAndUpdate(filter, updatedTick, { new: true }) return await rs?.toObject() ?? null } + private async validateTick (tick: TickInput): Promise { + const climbIdAsUUID = muuid.from(tick.climbId) + const climb = await this.climbModel.findOne({ _id: climbIdAsUUID, _deleting: { $eq: null } }).lean() + if (climb == null) { + throw new Error('Climb not found') + } + // Some imports from MP have both a route and boulder grade for example The heart route on el cap + const isBoulderingOnly = (climb.type.bouldering === true) && Object.keys(climb.type).every(key => key === 'bouldering' || climb.type[key] === false) + // bouldering only. solo, lead, follow, tr only applicable to roped climbs + if (isBoulderingOnly) { + if ((!['Send', 'Flash', 'Attempt'].includes(tick.attemptType)) || (['Lead', 'Solo', 'Tr', 'Follow'].includes(tick.style))) { + throw new Error('Invalid attempt type or style for bouldering') + } + // Only applicable to boulders or roped climbs with "lead" style + } else if (!(tick.style === 'Lead')) { + if ((['Attempt', 'Redpoint', 'Pinkpoint', 'Flash', 'Onsight'].includes(tick.attemptType))) { + throw new Error('Invalid attempt type for a non-lead style') + } + } + } + /** * @param ticks an array of ticks, with the Mountain Project id already hashed to the OpenTacos id * @returns an array of ticks, just created in the database diff --git a/src/model/__tests__/ticks.ts b/src/model/__tests__/ticks.ts index 6140f927..16148987 100644 --- a/src/model/__tests__/ticks.ts +++ b/src/model/__tests__/ticks.ts @@ -6,13 +6,34 @@ import muuid from 'uuid-mongodb' import UserDataSource from '../UserDataSource.js' import { UpdateProfileGQLInput } from '../../db/UserTypes.js' import inMemoryDB from '../../utils/inMemoryDB.js' +import { ClimbChangeInputType } from '../../db/ClimbTypes.js' +import MutableClimbDataSource from '../MutableClimbDataSource.js' +import MutableAreaDataSource from '../MutableAreaDataSource.js' const userId = muuid.v4() +const newClimbsToAdd: ClimbChangeInputType[] = [ + { + name: 'Sport 1', + // Intentionally disable TS check to make sure input is sanitized + disciplines: { + sport: true + }, + description: 'The best climb', + location: '5m left of the big tree', + protection: '5 quickdraws' + }, + { + name: 'Deep water 1', + disciplines: { + deepwatersolo: true + } + } +] const toTest: TickInput = { name: 'Small Dog', notes: 'Sandbagged', - climbId: 'c76d2083-6b8f-524a-8fb8-76e1dc79833f', + climbId: 'tbd', // need to create a climb for tick validation userId: userId.toUUID().toString(), style: 'Lead', attemptType: 'Onsight', @@ -24,7 +45,7 @@ const toTest: TickInput = { const toTest2: TickInput = { name: 'Sloppy Peaches', notes: 'v sloppy', - climbId: 'b767d949-0daf-5af3-b1f1-626de8c84b2a', + climbId: 'tbd', userId: userId.toUUID().toString(), style: 'Lead', attemptType: 'Flash', @@ -33,9 +54,9 @@ const toTest2: TickInput = { source: 'MP' } -const tickUpdate: TickInput = produce(toTest, draft => { +let tickUpdate: TickInput = produce(toTest, draft => { draft.notes = 'Not sandbagged' - draft.attemptType = 'Fell/Hung' + draft.attemptType = 'Flash' draft.source = 'OB' }) @@ -45,6 +66,8 @@ const testImport: TickInput[] = [ describe('Ticks', () => { let ticks: TickDataSource + let climbs: MutableClimbDataSource + let areas: MutableAreaDataSource const tickModel = getTickModel() let users: UserDataSource @@ -61,7 +84,22 @@ describe('Ticks', () => { } ticks = TickDataSource.getInstance() + climbs = MutableClimbDataSource.getInstance() users = UserDataSource.getInstance() + areas = MutableAreaDataSource.getInstance() + // Add climbs because add/update tick requires type validation + await areas.addCountry('usa') + const newDestination = await areas.addArea(userId, 'California', null, 'usa') + if (newDestination == null) fail('Expect new area to be created') + + const routesArea = await areas.addArea(userId, 'Sport & Trad', newDestination.metadata.area_id) + + const newIDs = await climbs.addOrUpdateClimbs(userId, routesArea.metadata.area_id, newClimbsToAdd) + + // Update tick inputs with generated climb IDs + toTest.climbId = newIDs[0] + toTest2.climbId = newIDs[1] + tickUpdate = { ...tickUpdate, climbId: newIDs[0] } // Ensure tickUpdate has the correct climbId }) afterAll(async () => { @@ -149,7 +187,7 @@ describe('Ticks', () => { }) it('should grab all ticks by userId and climbId', async () => { - const climbId = 'c76d2083-6b8f-524a-8fb8-76e1dc79833f' + const climbId = toTest.climbId const tick = await ticks.addTick(toTest) const tick2 = await ticks.addTick(toTest2) @@ -175,7 +213,6 @@ describe('Ticks', () => { it('should only delete MP imports', async () => { const MPTick = await ticks.addTick(toTest) const OBTick = await ticks.addTick(tickUpdate) - if (MPTick == null || OBTick == null) { fail('Should add two new ticks') } @@ -185,4 +222,10 @@ describe('Ticks', () => { expect(newTick?._id).toEqual(OBTick._id) expect(newTick?.notes).toEqual('Not sandbagged') }) + + it('Should test validation', async () => { + toTest2.style = 'TR' + toTest2.attemptType = 'Redpoint' + await expect(ticks.addTick(toTest2)).rejects.toThrow('Invalid attempt type for a non-lead style') + }) }) From 1113888587b43264288fd7a5977090619675ac52 Mon Sep 17 00:00:00 2001 From: Prith Date: Wed, 22 Jan 2025 16:22:49 -0700 Subject: [PATCH 2/7] remove console log --- src/__tests__/ticks.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/__tests__/ticks.ts b/src/__tests__/ticks.ts index e038b642..224fb8b8 100644 --- a/src/__tests__/ticks.ts +++ b/src/__tests__/ticks.ts @@ -80,7 +80,6 @@ describe('ticks API', () => { const newIDs = await climbs.addOrUpdateClimbs(user, routesArea.metadata.area_id, newClimbsToAdd) // Update tick inputs with generated climb IDs tickOne.climbId = newIDs[0] - console.log('newIDs[0] ', newIDs[0]) }) afterAll(async () => { @@ -126,7 +125,6 @@ describe('ticks API', () => { username: 'cat.dog', email: 'cat@example.com' } - console.log('tickOne ', tickOne) await users.createOrUpdateUserProfile(user, userProfileInput) await ticks.addTick(tickOne) const response = await queryAPI({ From d3e4fa26a479293825dea13d1fa11e0ba279bf1f Mon Sep 17 00:00:00 2001 From: prithipal khalsa Date: Fri, 24 Jan 2025 11:12:44 -0700 Subject: [PATCH 3/7] explicit tick check and testing --- src/db/TickSchema.ts | 4 +- src/db/TickTypes.ts | 14 +- src/graphql/schema/Tick.gql | 12 +- src/model/TickDataSource.ts | 81 ++++++--- src/model/__tests__/tickValidation.ts | 228 ++++++++++++++++++++++++++ src/model/__tests__/ticks.ts | 7 - 6 files changed, 300 insertions(+), 46 deletions(-) create mode 100644 src/model/__tests__/tickValidation.ts diff --git a/src/db/TickSchema.ts b/src/db/TickSchema.ts index e450b4d1..744deec1 100644 --- a/src/db/TickSchema.ts +++ b/src/db/TickSchema.ts @@ -16,8 +16,8 @@ export const TickSchema = new Schema({ notes: { type: Schema.Types.String, required: false }, climbId: { type: Schema.Types.String, required: true, index: true }, userId: { type: Schema.Types.String, required: true, index: true }, - style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow'], required: false }, - attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Frenchfree', 'Fell/Hung', 'Send', 'Attempt'], required: false, index: true }, + style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid'], required: false }, + attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Send', 'Attempt'], required: false, index: true }, dateClimbed: { type: Schema.Types.Date }, grade: { type: Schema.Types.String, required: true, index: true }, // Bear in mind that these enum types must be kept in sync with the TickSource enum diff --git a/src/db/TickTypes.ts b/src/db/TickTypes.ts index 14451737..3ae79fa9 100644 --- a/src/db/TickTypes.ts +++ b/src/db/TickTypes.ts @@ -16,12 +16,12 @@ export type TickSource = */ 'MP' -export type TickStyle = 'Lead' | 'Solo' | 'TR' | 'Follow' -export type TickAttemptType = 'Onsight' | 'Flash' | 'Redpoint' | 'Pinkpoint' | 'Frenchfree' | 'Fell/Hung' | 'Send' | 'Attempt' +export type TickStyle = 'Lead' | 'Solo' | 'TR' | 'Follow' | 'Aid' +export type TickAttemptType = 'Onsight' | 'Flash' | 'Pinkpoint' | 'Frenchfree' | 'Send' | 'Attempt' export const TickSourceValues: TickSource[] = ['OB', 'MP'] -export const TickStyleValues: TickStyle[] = ['Lead', 'Solo', 'TR', 'Follow'] -export const TickAttemptTypeValues: TickAttemptType[] = ['Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Frenchfree', 'Fell/Hung', 'Send', 'Attempt'] +export const TickStyleValues: TickStyle[] = ['Lead', 'Solo', 'TR', 'Follow', 'Aid'] +export const TickAttemptTypeValues: TickAttemptType[] = ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Send', 'Attempt'] /** Ticks * Ticks represent log entries for a user's climbing activity. They contain @@ -71,15 +71,15 @@ export interface TickInput { * If this is a native tick, you can enforce updated values here by referencing * the climb document (climbId -> climbs:uuid) */ - style: TickStyle + style?: TickStyle /** * Describe the type of successful attempt that was made here. - * Fell/Hung, Flash, Redpoint, Onsight, would be examples of values you might find here. + * Attempt, Flash, Redpoint, Onsight, would be examples of values you might find here. * This is again a free-form field. Data of practically any descriptive nature may find * itself here. */ - attemptType: TickAttemptType + attemptType?: TickAttemptType /** * Not the same as date created. Ticks can be back-filled by the user, and do diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index 0fdd9ffa..a88c572a 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -84,7 +84,7 @@ type TickType { style: TickStyle """ Describe the type of successful attempt that was made here. - Fell/Hung, Flash, Redpoint, Onsight, would be examples of values you might find here. + Attempt, Flash, Redpoint, Onsight, would be examples of values you might find here. This is again a free-form field. Data of practically any descriptive nature may find itself here. """ @@ -126,17 +126,13 @@ enum TickAttemptType { Onsight "Flash: Sending a route first try with prior knowledge" Flash - "Redpoint: Sending a route with no falls" - Redpoint "Pinkpoint: Sending a route with no falls on pre-placed gear" Pinkpoint "French Free: Doing the route with pulling on gear or draws" - French_Free - "Fell/Hung: Falling or hanging on gear" - Fell_Hung + FrenchFree "Attempt" Attempt - "Send" + "Send: Sending a route with no falls" Send } @@ -150,6 +146,8 @@ enum TickStyle { TR "Follow" Follow + "Aid" + Aid } diff --git a/src/model/TickDataSource.ts b/src/model/TickDataSource.ts index 11aaeb87..0221a856 100644 --- a/src/model/TickDataSource.ts +++ b/src/model/TickDataSource.ts @@ -65,37 +65,72 @@ export default class TickDataSource extends MongoDataSource { return await rs?.toObject() ?? null } + /** + * @param ticks an array of ticks, with the Mountain Project id already hashed to the OpenTacos id + * @returns an array of ticks, just created in the database + */ + async importTicks (ticks: TickInput[]): Promise { + if (ticks.length > 0) { + const res: TickType[] = await this.tickModel.insertMany(ticks) + return res + } else { + throw new Error("Can't import an empty tick list, check your import url or mutation") + } + } + private async validateTick (tick: TickInput): Promise { const climbIdAsUUID = muuid.from(tick.climbId) - const climb = await this.climbModel.findOne({ _id: climbIdAsUUID, _deleting: { $eq: null } }).lean() + const climb = await this.climbModel + .findOne({ _id: climbIdAsUUID, _deleting: { $eq: null } }) + .lean() if (climb == null) { throw new Error('Climb not found') } - // Some imports from MP have both a route and boulder grade for example The heart route on el cap - const isBoulderingOnly = (climb.type.bouldering === true) && Object.keys(climb.type).every(key => key === 'bouldering' || climb.type[key] === false) - // bouldering only. solo, lead, follow, tr only applicable to roped climbs - if (isBoulderingOnly) { - if ((!['Send', 'Flash', 'Attempt'].includes(tick.attemptType)) || (['Lead', 'Solo', 'Tr', 'Follow'].includes(tick.style))) { - throw new Error('Invalid attempt type or style for bouldering') + // Getting the climb singular type to verifiy, some climbs have multiple types such as the heart route on elcap (13b/v10) + const isDWSOnly = + (climb.type.deepwatersolo === true) && + Object.keys(climb.type).every( + key => key === 'deepwatersolo' || climb.type[key] === false) + + const isBoulderingOnly = + (climb.type.bouldering === true) && + Object.keys(climb.type).every( + key => key === 'bouldering' || climb.type[key] === false) + + const isTROnly = + (climb.type.tr === true) && + Object.keys(climb.type).every( + key => key === 'tr' || climb.type[key] === false) + + const isAidOnly = + (climb.type.aid === true) && + Object.keys(climb.type).every( + key => key === 'aid' || climb.type[key] === false) + + const isTradSportAlpineIceMixedAid = + ['trad', 'sport', 'alpine', 'ice', 'mixed', 'aid'].some( + type => climb.type[type] === true) + + const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined + const attemptType = tick.attemptType ?? 'null' // Provide a default value if tick.attempy is undefined + if (isDWSOnly || isBoulderingOnly) { // bouldering and dws can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and should have no sytle + if ((['Lead', 'Solo', 'Tr', 'Follow', 'Aid'].includes(tickStyle)) || ['Pinkpoint', 'Frenchfree'].includes(attemptType)) { + throw new Error('Invalid attempt type or style for DWS/Bouldering') } - // Only applicable to boulders or roped climbs with "lead" style - } else if (!(tick.style === 'Lead')) { - if ((['Attempt', 'Redpoint', 'Pinkpoint', 'Flash', 'Onsight'].includes(tick.attemptType))) { - throw new Error('Invalid attempt type for a non-lead style') + } else if (isTROnly) { // TopRope can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and styles: 'TR' + if (!['TR', 'null'].includes(tickStyle) || ['Pinkpoint', 'Frenchfree'].includes(attemptType)) { + throw new Error('Invalid attempt type or style for TR only') + } + } else if (isAidOnly) { // Aid can only have attempt types: 'Send', 'Attempt' and styles: 'Aid', 'Follow' + if (!['Aid', 'Follow', 'null'].includes(tickStyle) || ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree'].includes(attemptType)) { + throw new Error('Invalid attempt type or style for Aid only') + } + } else if (isTradSportAlpineIceMixedAid) { // roped climbs that aren't lead must have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' + if (['Solo', 'TR', 'Follow'].includes(tickStyle) && ['Pinkpoint', 'Frenchfree'].includes(attemptType)) { + throw new Error('Invalid attempt type for Solo/TR/Follow style') } - } - } - - /** - * @param ticks an array of ticks, with the Mountain Project id already hashed to the OpenTacos id - * @returns an array of ticks, just created in the database - */ - async importTicks (ticks: TickInput[]): Promise { - if (ticks.length > 0) { - const res: TickType[] = await this.tickModel.insertMany(ticks) - return res } else { - throw new Error("Can't import an empty tick list, check your import url or mutation") + throw new Error('Invalid climb type') } } diff --git a/src/model/__tests__/tickValidation.ts b/src/model/__tests__/tickValidation.ts new file mode 100644 index 00000000..53593703 --- /dev/null +++ b/src/model/__tests__/tickValidation.ts @@ -0,0 +1,228 @@ +import { produce } from 'immer' +import TickDataSource from '../TickDataSource.js' +import { getTickModel, getUserModel } from '../../db/index.js' +import { TickInput } from '../../db/TickTypes.js' +import muuid from 'uuid-mongodb' +import inMemoryDB from '../../utils/inMemoryDB.js' +import { ClimbChangeInputType } from '../../db/ClimbTypes.js' +import MutableClimbDataSource from '../MutableClimbDataSource.js' +import MutableAreaDataSource from '../MutableAreaDataSource.js' + +const userId = muuid.v4() +const newClimbsToAdd: ClimbChangeInputType[] = [ + { + name: 'Sport 1', + disciplines: { + sport: true + }, + description: 'The best climb', + location: '5m left of the big tree', + protection: '5 quickdraws' + }, + { + name: 'Deep water 1', + disciplines: { + deepwatersolo: true + } + }, + { + name: 'Boulder 1', + disciplines: { + bouldering: true + } + }, + { + name: 'Top Rope 1', + disciplines: { + tr: true + } + }, + { + name: 'Aid 1', + disciplines: { + aid: true + } + } +] + +const toTestSport: TickInput = { + name: 'Small Dog', + notes: 'Sandbagged', + climbId: 'tbd', // need to create a climb for tick validation + userId: userId.toUUID().toString(), + style: 'Lead', + attemptType: 'Onsight', + dateClimbed: new Date('2012-12-12'), + grade: '5.7', + source: 'MP' +} + +const toTestDWS: TickInput = { + name: 'Sloppy Peaches', + notes: 'v sloppy', + climbId: 'tbd', + userId: userId.toUUID().toString(), + attemptType: 'Flash', + dateClimbed: new Date('2012-10-15'), + grade: '5.10', + source: 'MP' +} + +const toTestBoulder: TickInput = { + name: 'Boulder or DWS', + notes: 'wet!', + climbId: 'tbd', + userId: userId.toUUID().toString(), + attemptType: 'Flash', + dateClimbed: new Date('2012-10-15'), + grade: 'v4', + source: 'OB' +} + +const toTestTR: TickInput = { + name: 'Top Rope Climb', + notes: 'Nice climb', + climbId: 'tbd', + userId: userId.toUUID().toString(), + style: 'TR', + attemptType: 'Send', + dateClimbed: new Date('2012-10-15'), + grade: '5.10', + source: 'OB' +} + +const toTestAid: TickInput = { + name: 'Aid Climb', + notes: 'Challenging', + climbId: 'tbd', + userId: userId.toUUID().toString(), + style: 'Aid', + attemptType: 'Send', + dateClimbed: new Date('2012-10-15'), + grade: 'A2', + source: 'OB' +} + +let tickUpdate: TickInput = produce(toTestSport, draft => { + draft.notes = 'Not sandbagged' + draft.attemptType = 'Flash' + draft.source = 'OB' +}) + +describe('Tick Validation', () => { + let ticks: TickDataSource + let climbs: MutableClimbDataSource + let areas: MutableAreaDataSource + const tickModel = getTickModel() + + beforeAll(async () => { + console.log('#BeforeAll Tick Validation') + await inMemoryDB.connect() + + try { + await getTickModel().collection.drop() + await getUserModel().collection.drop() + } catch (e) { + console.log('Cleaning db') + } + + ticks = TickDataSource.getInstance() + climbs = MutableClimbDataSource.getInstance() + areas = MutableAreaDataSource.getInstance() + // Add climbs because add/update tick requires type validation + await areas.addCountry('usa') + const newDestination = await areas.addArea(userId, 'California', null, 'usa') + if (newDestination == null) fail('Expect new area to be created') + + const routesArea = await areas.addArea(userId, 'Sport & Trad', newDestination.metadata.area_id) + + const newIDs = await climbs.addOrUpdateClimbs(userId, routesArea.metadata.area_id, newClimbsToAdd) + + // Update tick inputs with generated climb IDs + toTestSport.climbId = newIDs[0] + toTestDWS.climbId = newIDs[1] + toTestBoulder.climbId = newIDs[2] + toTestTR.climbId = newIDs[3] + toTestAid.climbId = newIDs[4] + tickUpdate = { ...tickUpdate, climbId: newIDs[0] } // Ensure tickUpdate has the correct climbId + }) + + afterAll(async () => { + await inMemoryDB.close() + }) + + afterEach(async () => { + await getTickModel().collection.drop() + await tickModel.ensureIndexes() + }) + + it('should validate tick for sport climb', async () => { + await expect(ticks.addTick(toTestSport)).resolves.not.toThrow() + }) + + it('should validate tick for deep water solo climb', async () => { + const dwsTick: TickInput = { + ...toTestDWS, + attemptType: 'Send' + } + await expect(ticks.addTick(dwsTick)).resolves.not.toThrow() + }) + + it('should throw error for invalid attempt type for deep water solo climb', async () => { + const invalidDwsTick: TickInput = { + ...toTestDWS, + attemptType: 'Pinkpoint' + } + await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering') + }) + + it('should throw error for invalid style for deep water solo climb', async () => { + const invalidDwsTick: TickInput = { + ...toTestDWS, + style: 'Lead', + attemptType: 'Send' + } + await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering') + }) + + it('should validate tick for top rope climb', async () => { + await expect(ticks.addTick(toTestTR)).resolves.not.toThrow() + }) + + it('should throw error for invalid attempt type for top rope climb', async () => { + const invalidTrTick: TickInput = { + ...toTestTR, + attemptType: 'Pinkpoint' + } + await expect(ticks.addTick(invalidTrTick)).rejects.toThrow('Invalid attempt type or style for TR only') + }) + + it('should validate tick for aid climb', async () => { + await expect(ticks.addTick(toTestAid)).resolves.not.toThrow() + }) + + it('should throw error for invalid attempt type for aid climb', async () => { + const invalidAidTick: TickInput = { + ...toTestAid, + attemptType: 'Flash' + } + await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only') + }) + + it('should throw error for invalid style for aid climb', async () => { + const invalidAidTick: TickInput = { + ...toTestAid, + style: 'Lead', + attemptType: 'Send' + } + await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only') + }) + + it('should validate tick with no attempt type', async () => { + const noAttemptTypeTick: TickInput = { + ...toTestSport, + attemptType: undefined + } + await expect(ticks.addTick(noAttemptTypeTick)).resolves.not.toThrow() + }) +}) diff --git a/src/model/__tests__/ticks.ts b/src/model/__tests__/ticks.ts index 16148987..96baf716 100644 --- a/src/model/__tests__/ticks.ts +++ b/src/model/__tests__/ticks.ts @@ -47,7 +47,6 @@ const toTest2: TickInput = { notes: 'v sloppy', climbId: 'tbd', userId: userId.toUUID().toString(), - style: 'Lead', attemptType: 'Flash', dateClimbed: new Date('2012-10-15'), grade: '5.10', @@ -222,10 +221,4 @@ describe('Ticks', () => { expect(newTick?._id).toEqual(OBTick._id) expect(newTick?.notes).toEqual('Not sandbagged') }) - - it('Should test validation', async () => { - toTest2.style = 'TR' - toTest2.attemptType = 'Redpoint' - await expect(ticks.addTick(toTest2)).rejects.toThrow('Invalid attempt type for a non-lead style') - }) }) From 931bb3c75e067f564613279bbc28bf1a1390bd75 Mon Sep 17 00:00:00 2001 From: prithipal khalsa Date: Fri, 24 Jan 2025 13:23:27 -0700 Subject: [PATCH 4/7] redpoint added for tick attemptType --- src/db/TickSchema.ts | 2 +- src/db/TickTypes.ts | 4 ++-- src/graphql/schema/Tick.gql | 2 ++ src/model/TickDataSource.ts | 6 +++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/db/TickSchema.ts b/src/db/TickSchema.ts index 744deec1..5de65be5 100644 --- a/src/db/TickSchema.ts +++ b/src/db/TickSchema.ts @@ -17,7 +17,7 @@ export const TickSchema = new Schema({ climbId: { type: Schema.Types.String, required: true, index: true }, userId: { type: Schema.Types.String, required: true, index: true }, style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid'], required: false }, - attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Send', 'Attempt'], required: false, index: true }, + attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt'], required: false, index: true }, dateClimbed: { type: Schema.Types.Date }, grade: { type: Schema.Types.String, required: true, index: true }, // Bear in mind that these enum types must be kept in sync with the TickSource enum diff --git a/src/db/TickTypes.ts b/src/db/TickTypes.ts index 3ae79fa9..890e9e24 100644 --- a/src/db/TickTypes.ts +++ b/src/db/TickTypes.ts @@ -17,11 +17,11 @@ export type TickSource = 'MP' export type TickStyle = 'Lead' | 'Solo' | 'TR' | 'Follow' | 'Aid' -export type TickAttemptType = 'Onsight' | 'Flash' | 'Pinkpoint' | 'Frenchfree' | 'Send' | 'Attempt' +export type TickAttemptType = 'Onsight' | 'Flash' | 'Pinkpoint' | 'Frenchfree' | 'Send' | 'Attempt' | 'Redpoint' export const TickSourceValues: TickSource[] = ['OB', 'MP'] export const TickStyleValues: TickStyle[] = ['Lead', 'Solo', 'TR', 'Follow', 'Aid'] -export const TickAttemptTypeValues: TickAttemptType[] = ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Send', 'Attempt'] +export const TickAttemptTypeValues: TickAttemptType[] = ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Send', 'Attempt', 'Redpoint'] /** Ticks * Ticks represent log entries for a user's climbing activity. They contain diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index a88c572a..15d57762 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -134,6 +134,8 @@ enum TickAttemptType { Attempt "Send: Sending a route with no falls" Send + "Redpoint" + Redpoint } diff --git a/src/model/TickDataSource.ts b/src/model/TickDataSource.ts index 0221a856..af3bffaf 100644 --- a/src/model/TickDataSource.ts +++ b/src/model/TickDataSource.ts @@ -114,11 +114,11 @@ export default class TickDataSource extends MongoDataSource { const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined const attemptType = tick.attemptType ?? 'null' // Provide a default value if tick.attempy is undefined if (isDWSOnly || isBoulderingOnly) { // bouldering and dws can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and should have no sytle - if ((['Lead', 'Solo', 'Tr', 'Follow', 'Aid'].includes(tickStyle)) || ['Pinkpoint', 'Frenchfree'].includes(attemptType)) { + if ((['Lead', 'Solo', 'Tr', 'Follow', 'Aid'].includes(tickStyle)) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { throw new Error('Invalid attempt type or style for DWS/Bouldering') } } else if (isTROnly) { // TopRope can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and styles: 'TR' - if (!['TR', 'null'].includes(tickStyle) || ['Pinkpoint', 'Frenchfree'].includes(attemptType)) { + if (!['TR', 'null'].includes(tickStyle) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { throw new Error('Invalid attempt type or style for TR only') } } else if (isAidOnly) { // Aid can only have attempt types: 'Send', 'Attempt' and styles: 'Aid', 'Follow' @@ -126,7 +126,7 @@ export default class TickDataSource extends MongoDataSource { throw new Error('Invalid attempt type or style for Aid only') } } else if (isTradSportAlpineIceMixedAid) { // roped climbs that aren't lead must have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' - if (['Solo', 'TR', 'Follow'].includes(tickStyle) && ['Pinkpoint', 'Frenchfree'].includes(attemptType)) { + if (['Solo', 'TR', 'Follow'].includes(tickStyle) && ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { throw new Error('Invalid attempt type for Solo/TR/Follow style') } } else { From 9aea95d2cb815251b4fd175b9f295df1382cb635 Mon Sep 17 00:00:00 2001 From: Prith Date: Mon, 27 Jan 2025 16:00:32 -0700 Subject: [PATCH 5/7] FrenchFree to frenchfree --- src/graphql/schema/Tick.gql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index 15d57762..c59b3433 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -129,7 +129,7 @@ enum TickAttemptType { "Pinkpoint: Sending a route with no falls on pre-placed gear" Pinkpoint "French Free: Doing the route with pulling on gear or draws" - FrenchFree + Frenchfree "Attempt" Attempt "Send: Sending a route with no falls" From 1341fd5fdfa2962236440a8bc884edb798138a6c Mon Sep 17 00:00:00 2001 From: Aaron Glasenapp Date: Wed, 29 Jan 2025 17:29:43 -0700 Subject: [PATCH 6/7] feat: allow null for tick fields & add markdown for tick logic --- .gitignore | 3 ++ README.md | 2 +- documentation/tick_logic.md | 73 +++++++++++++++++++++++++++++++++++++ src/db/TickSchema.ts | 6 +-- src/db/TickTypes.ts | 10 ++--- src/graphql/schema/Tick.gql | 4 +- 6 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 documentation/tick_logic.md diff --git a/.gitignore b/.gitignore index e54f7667..2949f9cd 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,9 @@ build tmp .env.local +# asdf +.tool-versions + # Intellij and VSCode .idea/ *.iml diff --git a/README.md b/README.md index 545b0593..a651a26d 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ These links explain the structure and key abstractions of our codebase. It's a g Start up your local server with `yarn serve-dev` (instead of `yarn serve`) ```bash - # Run this in open-tacos project + # Run this in openbeta-graphql project yarn serve-dev ``` diff --git a/documentation/tick_logic.md b/documentation/tick_logic.md new file mode 100644 index 00000000..4e630d0b --- /dev/null +++ b/documentation/tick_logic.md @@ -0,0 +1,73 @@ +# Tick logic + +Ticks are actually quite complicated, involving philosophical, linguistic, historical, cultural, and mathematical issues. Some people have strong opinions, different platforms handle ticks differently, and definitions of words evolve over time. Here are some thoughts to get you thinking and/or riled up: + +* Can you redpoint a boulder? +* Can you flash a toprope? +* Do you you send or Onsight a DWS? +* What does an "Attempt" mean on a solo? + +This document doesn't answer all the questions, It is merely a description of the architecture adopted by OpenBeta to try to best caputre how climbers generally want to keep track of their ticks, while restricting some obvious things that don't make sense, like ticking a Boulder route as a Frenchfree Solo, or a Toprope route as a Lead Pinkpoint + +## Architecture + +Let's get to the technical details. There are 3 layers to this architecturally in OB +* `Climb.type` +* `Tick.style` +* `Tick.attemptType` + +(Here)[https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115] are all the possible values for `Climb.type`, as defined in the [limb Schema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115), and style and attempts types defined in the [TickSchema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/db/TickTypes.ts). + +| Climb.type | Tick.style | Tick.attemptType | +|---------------|------------|------------------| +| trad | Lead | Onsight | +| sport | Follow | Flash | +| bouldering | TR | Redpoint | +| deepwatersolo | Solo | Pinkpoint | +| snow | Aid | Send | +| ice | Boulder | Attempt | +| aid | Frenchfree | | +| tr | | +| alpine | | +| mixed | | + + +See the [Wikipedia Glossary of Climbing Terms](https://en.wikipedia.org/wiki/Glossary_of_climbing_terms) for common definitions of all these terms. + +Given the 10 climb types, 6 styles, and 7 attempt types, there are 10*6*7=**420** diffent ways to "tick" a route. + +Here's a Hierarchical way to restrict values: + +## Climb type -> Tick Style + +| Climb Type | logical description | Tick Style Options | +|-------------------|---------------------|-------------------- | +| 'trad', 'sport', 'snow', 'ice', 'mixed', 'alpine' | leadable | Lead, Follow | +| 'tr' or leadable | topropeable | TR | +| 'aid' | aidable | Aid | +| 'deepwatersolo' or leadable or aidable or topropeable | soloable | Solo | +| bouldering | boulderable | Boulder | + +## Tick Style -> Tick Attempt Type + +| Tick Style | Attempt Type options | +|------------|----------------------| +| 'Lead' | 'Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Attempt', 'Frenchfree' | +| 'Follow' or 'TR' | 'Send', 'Attempt', 'Frenchfree' | +| 'Solo' | 'Onsight', 'Flash', 'Redpoint', 'Attempt' | +| 'Boulder' | 'Flash', 'Send', 'Attempt' | +| 'Aid' | 'Send', 'Attempt' | + +## A few justifications + +* While many people use terms like "trash" or "tronsight" to indicate a "toprope flash/onsight", most climbers dont use the terms "onsight", "flash" and "redpoint" for topropped routes. However, many people want to distinguish a "clean" TR ascent, so OB allows the generic term "send" for topropes. +* Common boulding nomenclature indicates that there is no such thing as an "onsight" of a boulder, only a flash, and instead of using the term "redpoint" for a send after multiple attempts, boulderes just use the generic term send. +* OB does not use the term "Fell/Hung" for roped climbs, and instead normalizes it to "Attempt", just like boulders. Importing routes from MP will convert "Fell/Hung" to "Attempt" +* While 'Frenchfree' and 'Aid' could be considered synomonous, some climbers may want to distinguish, for example, a multipitch route where one pitch was intentionally 'French freed' (Time Wave Zero being a common example), which is distinctly different in character than, eg: aiding the Nose on El Cap. +* Eventually, it might be cool to allow ticks for individual pitches, but that is not supported right now. +* Given the 420 possible combination, no simple logical system will perfectly capture every edge case. + + +## Importing from Mountain Project + +OP stores ticks a bit differently in the database than MP. Mainly around boulders. For Boulders, MP sets LeadStyle to null, and uses thh "style" field for Send, Attempt, and Flash. Open Beta sets the "style" to "boulder", and then uses attemptType to store Send, Attempt, and Flash, to be consistent with ticks for roped climbs. The Logic around [importing Ticks](https://github.com/OpenBeta/open-tacos/blob/develop/src/app/api/user/bulkImportTicks/route.ts), and tick validation ([frontend](https://github.com/OpenBeta/open-tacos/blob/develop/src/components/users/TickForm.tsx) and [backend](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/model/TickDataSource.ts)) is somewhat complicated, but it should result in fairly clean data in the OB database. \ No newline at end of file diff --git a/src/db/TickSchema.ts b/src/db/TickSchema.ts index 5de65be5..0510c522 100644 --- a/src/db/TickSchema.ts +++ b/src/db/TickSchema.ts @@ -16,10 +16,10 @@ export const TickSchema = new Schema({ notes: { type: Schema.Types.String, required: false }, climbId: { type: Schema.Types.String, required: true, index: true }, userId: { type: Schema.Types.String, required: true, index: true }, - style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid'], required: false }, - attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt'], required: false, index: true }, + style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder', null], required: false }, + attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt', null], required: false, index: true }, dateClimbed: { type: Schema.Types.Date }, - grade: { type: Schema.Types.String, required: true, index: true }, + grade: { type: Schema.Types.String, required: false, index: true }, // Bear in mind that these enum types must be kept in sync with the TickSource enum source: { type: Schema.Types.String, enum: ['MP', 'OB'] as TickSource[], required: true, index: true } }) diff --git a/src/db/TickTypes.ts b/src/db/TickTypes.ts index 890e9e24..59c4afc5 100644 --- a/src/db/TickTypes.ts +++ b/src/db/TickTypes.ts @@ -16,11 +16,11 @@ export type TickSource = */ 'MP' -export type TickStyle = 'Lead' | 'Solo' | 'TR' | 'Follow' | 'Aid' +export type TickStyle = 'Lead' | 'Solo' | 'TR' | 'Follow' | 'Aid' | 'Boulder' export type TickAttemptType = 'Onsight' | 'Flash' | 'Pinkpoint' | 'Frenchfree' | 'Send' | 'Attempt' | 'Redpoint' export const TickSourceValues: TickSource[] = ['OB', 'MP'] -export const TickStyleValues: TickStyle[] = ['Lead', 'Solo', 'TR', 'Follow', 'Aid'] +export const TickStyleValues: TickStyle[] = ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder'] export const TickAttemptTypeValues: TickAttemptType[] = ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Send', 'Attempt', 'Redpoint'] /** Ticks @@ -74,8 +74,8 @@ export interface TickInput { style?: TickStyle /** - * Describe the type of successful attempt that was made here. - * Attempt, Flash, Redpoint, Onsight, would be examples of values you might find here. + * Describe the type of attempt that was made here. + * Attempt, Flash, Redpoint, Onsight, Attempt would be examples of values you might find here. * This is again a free-form field. Data of practically any descriptive nature may find * itself here. */ @@ -94,7 +94,7 @@ export interface TickInput { * proper operation when importing ticks for entities that cannot be located * within OpenBeta's database. * */ - grade: string + grade?: string /** * we support any number of sources for tick import or native ticks, diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index c59b3433..3d4e5dcf 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -150,6 +150,8 @@ enum TickStyle { Follow "Aid" Aid + "Boulder" + Boulder } @@ -168,7 +170,7 @@ input Tick { style: TickStyle attemptType: TickAttemptType dateClimbed: Date! - grade: String! + grade: String source: TickSource! } From 72682259ee654b57362408d86a022f62a3509530 Mon Sep 17 00:00:00 2001 From: Aaron Glasenapp Date: Wed, 29 Jan 2025 17:29:43 -0700 Subject: [PATCH 7/7] feat: allow null for tick fields & add markdown for tick logic --- documentation/tick_logic.md | 38 ++++++----- src/db/TickSchema.ts | 4 +- src/graphql/schema/Tick.gql | 1 - src/model/TickDataSource.ts | 96 +++++++++++++++------------ src/model/__tests__/tickValidation.ts | 17 ++--- 5 files changed, 79 insertions(+), 77 deletions(-) diff --git a/documentation/tick_logic.md b/documentation/tick_logic.md index 4e630d0b..91e9b9b3 100644 --- a/documentation/tick_logic.md +++ b/documentation/tick_logic.md @@ -16,29 +16,29 @@ Let's get to the technical details. There are 3 layers to this architecturally i * `Tick.style` * `Tick.attemptType` -(Here)[https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115] are all the possible values for `Climb.type`, as defined in the [limb Schema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115), and style and attempts types defined in the [TickSchema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/db/TickTypes.ts). +Here are all the possible values for `Climb.type` (also called discipline), as defined in the [Climb Schema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/graphql/schema/Climb.gql#L115), and Tick style and attemptsTypes defined in the [TickSchema](https://github.com/OpenBeta/openbeta-graphql/blob/develop/src/db/TickTypes.ts). | Climb.type | Tick.style | Tick.attemptType | |---------------|------------|------------------| -| trad | Lead | Onsight | -| sport | Follow | Flash | -| bouldering | TR | Redpoint | -| deepwatersolo | Solo | Pinkpoint | -| snow | Aid | Send | -| ice | Boulder | Attempt | -| aid | Frenchfree | | -| tr | | -| alpine | | -| mixed | | +| trad | Lead | Onsight | +| sport | Follow | Flash | +| bouldering | TR | Redpoint | +| deepwatersolo | Solo | Pinkpoint | +| snow | Aid | Send | +| ice | Boulder | Attempt | +| aid | | Frenchfree | +| tr | | | +| alpine | | | +| mixed | | | See the [Wikipedia Glossary of Climbing Terms](https://en.wikipedia.org/wiki/Glossary_of_climbing_terms) for common definitions of all these terms. -Given the 10 climb types, 6 styles, and 7 attempt types, there are 10*6*7=**420** diffent ways to "tick" a route. +Given the 10 climb types, 6 styles, and 7 attempt types, there are `10*6*7=`**420** diffent ways to "tick" a route. *(Thats not even accounting for the fact that a route can be multiple disciplines, eg: boulder & TR, or sport & deepwatersolo. If you really want to get nerdy: with the `2^10=1024` possible discipline combinations, there are a whopping `1024*6*7=`**43,008** ways to tick a route!)* -Here's a Hierarchical way to restrict values: +## Here's a Hierarchical way to restrict values: -## Climb type -> Tick Style +### Climb type -> Tick Style | Climb Type | logical description | Tick Style Options | |-------------------|---------------------|-------------------- | @@ -48,15 +48,17 @@ Here's a Hierarchical way to restrict values: | 'deepwatersolo' or leadable or aidable or topropeable | soloable | Solo | | bouldering | boulderable | Boulder | -## Tick Style -> Tick Attempt Type + +Since a route can have multiple disciplines, these options are composable. eg: a route marked as 'trad, aid', is both 'leadable' and 'aidable'. A route that is 'boulder, tr', is both 'boulderable' and 'topropeable' + +### Tick Style -> Tick Attempt Type | Tick Style | Attempt Type options | |------------|----------------------| | 'Lead' | 'Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Attempt', 'Frenchfree' | -| 'Follow' or 'TR' | 'Send', 'Attempt', 'Frenchfree' | +| 'Follow', 'TR' or 'Aid | 'Send', 'Attempt' | | 'Solo' | 'Onsight', 'Flash', 'Redpoint', 'Attempt' | | 'Boulder' | 'Flash', 'Send', 'Attempt' | -| 'Aid' | 'Send', 'Attempt' | ## A few justifications @@ -65,7 +67,7 @@ Here's a Hierarchical way to restrict values: * OB does not use the term "Fell/Hung" for roped climbs, and instead normalizes it to "Attempt", just like boulders. Importing routes from MP will convert "Fell/Hung" to "Attempt" * While 'Frenchfree' and 'Aid' could be considered synomonous, some climbers may want to distinguish, for example, a multipitch route where one pitch was intentionally 'French freed' (Time Wave Zero being a common example), which is distinctly different in character than, eg: aiding the Nose on El Cap. * Eventually, it might be cool to allow ticks for individual pitches, but that is not supported right now. -* Given the 420 possible combination, no simple logical system will perfectly capture every edge case. +* Given the 43,008 possible combinations, no simple logical system will perfectly capture every edge case. ## Importing from Mountain Project diff --git a/src/db/TickSchema.ts b/src/db/TickSchema.ts index 0510c522..a190bf08 100644 --- a/src/db/TickSchema.ts +++ b/src/db/TickSchema.ts @@ -16,8 +16,8 @@ export const TickSchema = new Schema({ notes: { type: Schema.Types.String, required: false }, climbId: { type: Schema.Types.String, required: true, index: true }, userId: { type: Schema.Types.String, required: true, index: true }, - style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder', null], required: false }, - attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt', null], required: false, index: true }, + style: { type: Schema.Types.String, enum: ['Lead', 'Solo', 'TR', 'Follow', 'Aid', 'Boulder'], required: false }, + attemptType: { type: Schema.Types.String, enum: ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree', 'Redpoint', 'Send', 'Attempt'], required: false, index: true }, dateClimbed: { type: Schema.Types.Date }, grade: { type: Schema.Types.String, required: false, index: true }, // Bear in mind that these enum types must be kept in sync with the TickSource enum diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index 3d4e5dcf..5af9b218 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -136,7 +136,6 @@ enum TickAttemptType { Send "Redpoint" Redpoint - } enum TickStyle { diff --git a/src/model/TickDataSource.ts b/src/model/TickDataSource.ts index af3bffaf..5afdef71 100644 --- a/src/model/TickDataSource.ts +++ b/src/model/TickDataSource.ts @@ -86,51 +86,59 @@ export default class TickDataSource extends MongoDataSource { if (climb == null) { throw new Error('Climb not found') } - // Getting the climb singular type to verifiy, some climbs have multiple types such as the heart route on elcap (13b/v10) - const isDWSOnly = - (climb.type.deepwatersolo === true) && - Object.keys(climb.type).every( - key => key === 'deepwatersolo' || climb.type[key] === false) - - const isBoulderingOnly = - (climb.type.bouldering === true) && - Object.keys(climb.type).every( - key => key === 'bouldering' || climb.type[key] === false) - - const isTROnly = - (climb.type.tr === true) && - Object.keys(climb.type).every( - key => key === 'tr' || climb.type[key] === false) - - const isAidOnly = - (climb.type.aid === true) && - Object.keys(climb.type).every( - key => key === 'aid' || climb.type[key] === false) - - const isTradSportAlpineIceMixedAid = - ['trad', 'sport', 'alpine', 'ice', 'mixed', 'aid'].some( - type => climb.type[type] === true) - - const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined + + // Tick validation logic is complicated. see [tick_logic.md](https://github.com/OpenBeta/openbeta-graphql/blob/develop/documentation/tick_logic.md). + + const tickStyle = tick.style ?? 'null' // Provide a default value if tick.style is undefined. This 'null' string is not saved in the db, but used for easy validation. const attemptType = tick.attemptType ?? 'null' // Provide a default value if tick.attempy is undefined - if (isDWSOnly || isBoulderingOnly) { // bouldering and dws can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and should have no sytle - if ((['Lead', 'Solo', 'Tr', 'Follow', 'Aid'].includes(tickStyle)) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { - throw new Error('Invalid attempt type or style for DWS/Bouldering') - } - } else if (isTROnly) { // TopRope can only have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' and styles: 'TR' - if (!['TR', 'null'].includes(tickStyle) || ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { - throw new Error('Invalid attempt type or style for TR only') - } - } else if (isAidOnly) { // Aid can only have attempt types: 'Send', 'Attempt' and styles: 'Aid', 'Follow' - if (!['Aid', 'Follow', 'null'].includes(tickStyle) || ['Onsight', 'Flash', 'Pinkpoint', 'Frenchfree'].includes(attemptType)) { - throw new Error('Invalid attempt type or style for Aid only') - } - } else if (isTradSportAlpineIceMixedAid) { // roped climbs that aren't lead must have attempt types: 'Send', 'Flash', 'Attempt', 'Onsight' - if (['Solo', 'TR', 'Follow'].includes(tickStyle) && ['Pinkpoint', 'Frenchfree', 'Redpoint'].includes(attemptType)) { - throw new Error('Invalid attempt type for Solo/TR/Follow style') - } - } else { - throw new Error('Invalid climb type') + + const leadable = ['trad', 'sport', 'snow', 'ice', 'mixed', 'alpine'].some(type => climb.type[type] === true) + const topropeable = (climb.type.tr === true) || leadable + const aidable = climb.type.aid === true + const boulderable = climb.type.bouldering === true + const soloable = (climb.type.deepwatersolo === true) || leadable || aidable || (topropeable && !boulderable) + + // Validate tick style for each climb type + if (!leadable && (['Lead', 'Follow'].includes(tickStyle))) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!topropeable && (tickStyle === 'TR')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!aidable && (tickStyle === 'Aid')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!boulderable && (tickStyle === 'Boulder')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + if (!soloable && (tickStyle === 'Solo')) { + throw new Error(`Invalid style ${tickStyle} for climb type`) + } + + // validate attempt type for each tick style + switch (tickStyle) { + case 'Lead': + if (!['Onsight', 'Flash', 'Redpoint', 'Pinkpoint', 'Attempt', 'Frenchfree', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for Lead style`) + } + break + case 'Solo': + if (!['Onsight', 'Flash', 'Redpoint', 'Attempt', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for Solo style`) + } + break + case 'Boulder': + if (!['Flash', 'Send', 'Attempt', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for Boulder style`) + } + break + case 'TR': + case 'Follow': + case 'Aid': + if (!['Send', 'Attempt', 'null'].includes(attemptType)) { + throw new Error(`Invalid attempt type ${attemptType} for TR/Follow/Aid style`) + } + break } } diff --git a/src/model/__tests__/tickValidation.ts b/src/model/__tests__/tickValidation.ts index 53593703..98599479 100644 --- a/src/model/__tests__/tickValidation.ts +++ b/src/model/__tests__/tickValidation.ts @@ -73,6 +73,7 @@ const toTestBoulder: TickInput = { notes: 'wet!', climbId: 'tbd', userId: userId.toUUID().toString(), + style: 'Boulder', attemptType: 'Flash', dateClimbed: new Date('2012-10-15'), grade: 'v4', @@ -168,21 +169,13 @@ describe('Tick Validation', () => { await expect(ticks.addTick(dwsTick)).resolves.not.toThrow() }) - it('should throw error for invalid attempt type for deep water solo climb', async () => { - const invalidDwsTick: TickInput = { - ...toTestDWS, - attemptType: 'Pinkpoint' - } - await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering') - }) - it('should throw error for invalid style for deep water solo climb', async () => { const invalidDwsTick: TickInput = { ...toTestDWS, style: 'Lead', attemptType: 'Send' } - await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid attempt type or style for DWS/Bouldering') + await expect(ticks.addTick(invalidDwsTick)).rejects.toThrow('Invalid style Lead for climb type') }) it('should validate tick for top rope climb', async () => { @@ -194,7 +187,7 @@ describe('Tick Validation', () => { ...toTestTR, attemptType: 'Pinkpoint' } - await expect(ticks.addTick(invalidTrTick)).rejects.toThrow('Invalid attempt type or style for TR only') + await expect(ticks.addTick(invalidTrTick)).rejects.toThrow('Invalid attempt type Pinkpoint for TR/Follow/Aid style') }) it('should validate tick for aid climb', async () => { @@ -206,7 +199,7 @@ describe('Tick Validation', () => { ...toTestAid, attemptType: 'Flash' } - await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only') + await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type Flash for TR/Follow/Aid style') }) it('should throw error for invalid style for aid climb', async () => { @@ -215,7 +208,7 @@ describe('Tick Validation', () => { style: 'Lead', attemptType: 'Send' } - await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid attempt type or style for Aid only') + await expect(ticks.addTick(invalidAidTick)).rejects.toThrow('Invalid style Lead for climb type') }) it('should validate tick with no attempt type', async () => {