Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding tick restriction for attempt type #444

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/__tests__/ticks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -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',
Expand All @@ -45,7 +68,18 @@ 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]
})

afterAll(async () => {
Expand Down Expand Up @@ -202,7 +236,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',
Expand Down
4 changes: 2 additions & 2 deletions src/db/TickSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export const TickSchema = new Schema<TickType>({
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', 'Aid'], 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: true, index: true },
// Bear in mind that these enum types must be kept in sync with the TickSource enum
Expand Down
11 changes: 8 additions & 3 deletions src/db/TickTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ export type TickSource =
*/
'MP'

export type TickStyle = 'Lead' | 'Solo' | 'TR' | 'Follow' | 'Aid'
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', 'Redpoint']

/** Ticks
* Ticks represent log entries for a user's climbing activity. They contain
Expand Down Expand Up @@ -66,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: 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.
* 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: string
attemptType?: TickAttemptType

/**
* Not the same as date created. Ticks can be back-filled by the user, and do
Expand Down
44 changes: 39 additions & 5 deletions src/graphql/schema/Tick.gql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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: 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
Expand Down Expand Up @@ -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
"Pinkpoint: Sending a route with no falls on pre-placed gear"
Pinkpoint
"French Free: Doing the route with pulling on gear or draws"
FrenchFree

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frenchfree (instead of FrenchFree) to match above.

"Attempt"
Attempt
"Send: Sending a route with no falls"
Send
"Redpoint"
Redpoint

}

enum TickStyle {
"Lead"
Lead
"Solo"
Solo
"tr"
TR
"Follow"
Follow
"Aid"
Aid
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we add 'Boulder'? It's a little counter-intuitive to me that this field is blank for boulders, IMHO setting the style to Boulder makes sense.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if I agree with that. The UI (at least on the mobile app) just won't show this field. So for boulders this field doesn't make sense at all. This is similar to how MP shows ticks for boulders




"""
This is our tick type input, containing the name,
notes climbId, etc of the ticked climb, all fields are required
Expand All @@ -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!
Expand Down
68 changes: 64 additions & 4 deletions src/model/TickDataSource.ts
Original file line number Diff line number Diff line change
@@ -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<TickType> {
tickModel = getTickModel()
userModel = getUserModel()

climbModel = getClimbModel()
/**
* @param tick takes in a new tick
* @returns new tick
*/
async addTick (tick: TickInput): Promise<TickType> {
await this.validateTick(tick)
return await this.tickModel.create({ ...tick })
}

Expand Down Expand Up @@ -57,14 +60,15 @@ export default class TickDataSource extends MongoDataSource<TickType> {
* @returns the new/updated tick
*/
async editTick (filter: TickEditFilterType, updatedTick: TickInput): Promise<TickType | null> {
await this.validateTick(updatedTick)
const rs = await this.tickModel.findOneAndUpdate(filter, updatedTick, { new: true })
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
*/
* @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<TickType[]> {
if (ticks.length > 0) {
const res: TickType[] = await this.tickModel.insertMany(ticks)
Expand All @@ -74,6 +78,62 @@ export default class TickDataSource extends MongoDataSource<TickType> {
}
}

private async validateTick (tick: TickInput): Promise<void> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a request, could we make code a bit more readable, i.e. :

private async validateTick(tick: TickInput): Promise<void> {
  const climbIdAsUUID = muuid.from(tick.climbId);
  const climb = await this.climbModel
    .findOne({ _id: climbIdAsUUID, _deleting: { $eq: null } })
    .lean();

  if (!climb) {
    throw new Error('Climb not found');
  }

  // Check if the climb is exclusively a boulder
  const isBoulderingOnly = 
    climb.type.bouldering === true &&
    Object.keys(climb.type).every(
      key => key === 'bouldering' || climb.type[key] === false
    );

  if (isBoulderingOnly) {
    // Validate bouldering-specific attempt type and style
    const validBoulderingAttempts = ['Send', 'Flash', 'Attempt'];
    const invalidStyles = ['Lead', 'Solo', 'Tr', 'Follow'];

    if (
      !validBoulderingAttempts.includes(tick.attemptType) || 
      invalidStyles.includes(tick.style)
    ) {
      throw new Error('Invalid attempt type or style for bouldering');
    }
  } else {
    // Validate non-bouldering styles and attempt types
    const validRopedAttempts = ['Attempt', 'Redpoint', 'Pinkpoint', 'Flash', 'Onsight'];

    if (tick.style !== 'Lead' && validRopedAttempts.includes(tick.attemptType)) {
      throw new Error('Invalid attempt type for a non-lead style');
    }
  }
}

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')
}
// 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', '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'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one doesn't quite make sense to me.

  1. TR only routes can also be Soloed (so we should allow 'Solo' as a type in addition to TR)
  2. I thought we weren't going to allow 'Onsight' and 'Flash' for TR routes, and only allow 'Send'? I guess I can restrict that on the front end only, but allow those types in the DB.

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')
}
}

/**
* Retrieve ticks of a user given their details
* @param userSelectors Attributes that can be used to identify the user
Expand Down
Loading
Loading