diff --git a/src/__tests__/ticks.ts b/src/__tests__/ticks.ts index acdc3abb..728a25e4 100644 --- a/src/__tests__/ticks.ts +++ b/src/__tests__/ticks.ts @@ -4,6 +4,9 @@ import { jest } from '@jest/globals' import { queryAPI, setUpServer } from '../utils/testUtils.js' import { muuidToString } from '../utils/helpers.js' import { TickInput } from '../db/TickTypes.js' +import TickDataSource from '../model/TickDataSource.js' +import UserDataSource from '../model/UserDataSource.js' +import { UpdateProfileGQLInput } from '../db/UserTypes.js' jest.setTimeout(60000) @@ -14,13 +17,13 @@ describe('ticks API', () => { let inMemoryDB // Mongoose models for mocking pre-existing state. + let ticks: TickDataSource + let users: UserDataSource let tickOne: TickInput beforeAll(async () => { ({ server, inMemoryDB } = await setUpServer()) - // Auth0 serializes uuids in "relaxed" mode, resulting in this hex string format - // "59f1d95a-627d-4b8c-91b9-389c7424cb54" instead of base64 "WfHZWmJ9S4yRuTicdCTLVA==". - user = muuid.mode('relaxed').v4() + user = muuid.v4() userUuid = muuidToString(user) tickOne = { @@ -37,6 +40,8 @@ describe('ticks API', () => { }) beforeEach(async () => { + ticks = TickDataSource.getInstance() + users = UserDataSource.getInstance() await inMemoryDB.clear() }) @@ -45,6 +50,62 @@ describe('ticks API', () => { await inMemoryDB.close() }) + describe('queries', () => { + const userQuery = ` + query userTicks($userId: MUUID, $username: String) { + userTicks(userId: $userId, username: $username) { + _id + name + notes + climbId + style + attemptType + dateClimbed + grade + userId + } + } + ` + + it('queries by userId', async () => { + const userProfileInput: UpdateProfileGQLInput = { + userUuid, + username: 'cat.dog', + email: 'cat@example.com' + } + await users.createOrUpdateUserProfile(user, userProfileInput) + await ticks.addTick(tickOne) + const response = await queryAPI({ + query: userQuery, + variables: { userId: userUuid }, + userUuid + }) + expect(response.statusCode).toBe(200) + const res = response.body.data.userTicks + expect(res).toHaveLength(1) + expect(res[0].name).toBe(tickOne.name) + }) + + it('queries by username', async () => { + const userProfileInput: UpdateProfileGQLInput = { + userUuid, + username: 'cat.dog', + email: 'cat@example.com' + } + await users.createOrUpdateUserProfile(user, userProfileInput) + await ticks.addTick(tickOne) + const response = await queryAPI({ + query: userQuery, + variables: { username: 'cat.dog' }, + userUuid + }) + expect(response.statusCode).toBe(200) + const res = response.body.data.userTicks + expect(res).toHaveLength(1) + expect(res[0].name).toBe(tickOne.name) + }) + }) + describe('mutations', () => { const createQuery = ` mutation ($input: Tick!) { diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index 93edba40..af81045c 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -24,7 +24,7 @@ export const createContext = async ({ req }): Promise => { payload = await verifyJWT(token) } catch (e) { logger.error(`Can't verify JWT token ${e.toString() as string}`) - throw new Error('An unxpected error has occurred. Please notify us at support@openbeta.io.') + throw new Error('An unexpected error has occurred. Please notify us at support@openbeta.io.') } user.isBuilder = payload?.scope?.includes('builder:default') ?? false diff --git a/src/db/TickTypes.ts b/src/db/TickTypes.ts index 15b3101a..f2eb37ab 100644 --- a/src/db/TickTypes.ts +++ b/src/db/TickTypes.ts @@ -1,4 +1,5 @@ import mongoose from 'mongoose' +import { MUUID } from 'uuid-mongodb' /** * Ticks may be sourced from a number of places. They may come from external sources, @@ -101,3 +102,8 @@ export interface TickInput { export interface TickEditFilterType { _id: mongoose.Types.ObjectId } + +export interface TickUserSelectors { + userId?: MUUID + username?: string +} diff --git a/src/db/utils/jobs/migration/CreateUsersCollection.ts b/src/db/utils/jobs/migration/CreateUsersCollection.ts index a74a2673..3f19854e 100644 --- a/src/db/utils/jobs/migration/CreateUsersCollection.ts +++ b/src/db/utils/jobs/migration/CreateUsersCollection.ts @@ -8,7 +8,7 @@ import type { User as Auth0User } from 'auth0' import { connectDB, gracefulExit, getUserModel } from '../../../index.js' import { logger } from '../../../../logger.js' import { User, UpdateProfileGQLInput } from '../../../UserTypes.js' -import { nonAlphanumericRegex } from '../../../../model/UserDataSource.js' +import { canonicalizeUsername } from '../../../../utils/helpers.js' const LOCAL_MEDIA_DIR_UID = process.env.LOCAL_MEDIA_DIR_UID @@ -56,7 +56,7 @@ const onConnected = async (): Promise => { avatar, usernameInfo: { username, - canonicalName: username.replaceAll(nonAlphanumericRegex, ''), + canonicalName: canonicalizeUsername(username), updatedAt: new Date(ts) }, createdBy: userUuid diff --git a/src/graphql/schema/Tick.gql b/src/graphql/schema/Tick.gql index e7322a84..b03b8649 100644 --- a/src/graphql/schema/Tick.gql +++ b/src/graphql/schema/Tick.gql @@ -1,8 +1,8 @@ type Query { """ - Gets all of the users current ticks by their Auth-0 userId + Gets all of the users current ticks by their Auth-0 userId or username """ - userTicks(userId: String): [TickType] + userTicks(userId: MUUID, username: String): [TickType] """ Gets all of the users current ticks for a specific climb by their Auth-0 userId and Open-Beta ClimbId diff --git a/src/graphql/tick/TickQueries.ts b/src/graphql/tick/TickQueries.ts index 3e141604..f4352d96 100644 --- a/src/graphql/tick/TickQueries.ts +++ b/src/graphql/tick/TickQueries.ts @@ -1,16 +1,15 @@ -import { TickType } from '../../db/TickTypes' +import { TickType, TickUserSelectors } from '../../db/TickTypes' import type TickDataSource from '../../model/TickDataSource' const TickQueries = { - userTicks: async (_, input, { dataSources }): Promise => { + userTicks: async (_, input: TickUserSelectors, { dataSources }): Promise => { const { ticks }: { ticks: TickDataSource } = dataSources - const { userId } = input - return await ticks.ticksByUser(userId) + return await ticks.ticksByUser(input) }, userTicksByClimbId: async (_, input, { dataSources }): Promise => { const { ticks }: { ticks: TickDataSource } = dataSources const { climbId, userId } = input - return await ticks.ticksByUserAndClimb(userId, climbId) + return await ticks.ticksByUserIdAndClimb(userId, climbId) } } diff --git a/src/model/TickDataSource.ts b/src/model/TickDataSource.ts index 027976aa..b546ebd4 100644 --- a/src/model/TickDataSource.ts +++ b/src/model/TickDataSource.ts @@ -2,11 +2,13 @@ import { MongoDataSource } from 'apollo-datasource-mongodb' import type { DeleteResult } from 'mongodb' import mongoose from 'mongoose' -import { TickEditFilterType, TickInput, TickType } from '../db/TickTypes' -import { getTickModel } from '../db/index.js' +import { TickEditFilterType, TickInput, TickType, TickUserSelectors } from '../db/TickTypes' +import { getTickModel, getUserModel } from '../db/index.js' +import type { User } from '../db/UserTypes' export default class TickDataSource extends MongoDataSource { tickModel = getTickModel() + userModel = getUserModel() /** * @param tick takes in a new tick @@ -71,11 +73,39 @@ export default class TickDataSource extends MongoDataSource { } } - async ticksByUser (userId: string): Promise { - return await this.tickModel.find({ userId }) + /** + * Retrieve ticks of a user given their details + * @param userSelectors Attributes that can be used to identify the user + * @returns + */ + async ticksByUser (userSelectors: TickUserSelectors): Promise { + const { userId: requestedUserId, username } = userSelectors + if (requestedUserId == null && username == null) { + throw new Error('Username or userId must be supplied') + } + const filters: any[] = [] + if (requestedUserId != null) { + filters.push({ _id: requestedUserId }) + } + if (username != null) { + filters.push({ + 'usernameInfo.username': { + $exists: true, $eq: username + } + }) + } + const userIdObject = await this.userModel.findOne>( + { $or: filters }, + { _id: 1 } + ).lean() + if (userIdObject == null) { + throw new Error('No such user') + } + // Unfortunately, userIds on ticks are stored as strings not MUUIDs. + return await this.tickModel.find({ userId: userIdObject._id.toUUID().toString() }) } - async ticksByUserAndClimb (userId: string, climbId: string): Promise { + async ticksByUserIdAndClimb (userId: string, climbId: string): Promise { return await this.tickModel.find({ userId, climbId }) } diff --git a/src/model/UserDataSource.ts b/src/model/UserDataSource.ts index 57e015ea..b4848f86 100644 --- a/src/model/UserDataSource.ts +++ b/src/model/UserDataSource.ts @@ -12,11 +12,10 @@ import { UserPublicProfile } from '../db/UserTypes.js' import { trimToNull } from '../utils/sanitize.js' +import { canonicalizeUsername } from '../utils/helpers.js' const USERNAME_UPDATE_WAITING_IN_DAYS = 14 -export const nonAlphanumericRegex = /[\W_\s]+/g - export default class UserDataSource extends MongoDataSource { static PUBLIC_PROFILE_PROJECTION = { _id: 1, @@ -44,7 +43,7 @@ export default class UserDataSource extends MongoDataSource { const rs = await this.userModel.find( { 'usernameInfo.canonicalName': { - $exists: true, $eq: _username.replaceAll(nonAlphanumericRegex, '') + $exists: true, $eq: canonicalizeUsername(_username) } }, { @@ -119,7 +118,7 @@ export default class UserDataSource extends MongoDataSource { if (username != null) { usernameInfo = { username, - canonicalName: username.replaceAll(nonAlphanumericRegex, ''), + canonicalName: canonicalizeUsername(username), updatedAt: new Date() } } @@ -155,7 +154,7 @@ export default class UserDataSource extends MongoDataSource { } rs.set('usernameInfo.username', username) - rs.set('usernameInfo.canonicalName', username.replaceAll(nonAlphanumericRegex, '')) + rs.set('usernameInfo.canonicalName', canonicalizeUsername(username)) } if (displayName != null && displayName !== rs.displayName) { diff --git a/src/model/__tests__/ticks.ts b/src/model/__tests__/ticks.ts index 19c7ad6a..5b719944 100644 --- a/src/model/__tests__/ticks.ts +++ b/src/model/__tests__/ticks.ts @@ -1,14 +1,19 @@ import mongoose from 'mongoose' import { produce } from 'immer' import TickDataSource from '../TickDataSource.js' -import { connectDB, getTickModel } from '../../db/index.js' +import { connectDB, getTickModel, getUserModel } from '../../db/index.js' import { TickInput } from '../../db/TickTypes.js' +import muuid from 'uuid-mongodb' +import UserDataSource from '../UserDataSource.js' +import { UpdateProfileGQLInput } from '../../db/UserTypes.js' + +const userId = muuid.v4() const toTest: TickInput = { name: 'Small Dog', notes: 'Sandbagged', climbId: 'c76d2083-6b8f-524a-8fb8-76e1dc79833f', - userId: 'abc123', + userId: userId.toUUID().toString(), style: 'Lead', attemptType: 'Onsight', dateClimbed: new Date('2012-12-12'), @@ -20,7 +25,7 @@ const toTest2: TickInput = { name: 'Sloppy Peaches', notes: 'v sloppy', climbId: 'b767d949-0daf-5af3-b1f1-626de8c84b2a', - userId: 'abc123', + userId: userId.toUUID().toString(), style: 'Lead', attemptType: 'Flash', dateClimbed: new Date('2012-10-15'), @@ -42,17 +47,21 @@ describe('Ticks', () => { let ticks: TickDataSource const tickModel = getTickModel() + let users: UserDataSource + beforeAll(async () => { console.log('#BeforeAll Ticks') await connectDB() try { await getTickModel().collection.drop() + await getUserModel().collection.drop() } catch (e) { console.log('Cleaning db') } - ticks = new TickDataSource(mongoose.connection.db.collection('ticks')) + ticks = TickDataSource.getInstance() + users = UserDataSource.getInstance() }) afterAll(async () => { @@ -122,13 +131,19 @@ describe('Ticks', () => { }) it('should grab all ticks by userId', async () => { + const userProfileInput: UpdateProfileGQLInput = { + userUuid: userId.toUUID().toString(), + username: 'cat.dog', + email: 'cat@example.com' + } + await users.createOrUpdateUserProfile(userId, userProfileInput) const tick = await ticks.addTick(toTest) if (tick == null) { fail('Should add a new tick') } - const newTicks = await ticks.ticksByUser('abc123') + const newTicks = await ticks.ticksByUser({ userId }) expect(newTicks.length).toEqual(1) }) @@ -141,25 +156,23 @@ describe('Ticks', () => { if (tick == null || tick2 == null) { fail('Should add a new tick') } - const userClimbTicks = await ticks.ticksByUserAndClimb('abc123', climbId) + const userClimbTicks = await ticks.ticksByUserIdAndClimb(userId.toUUID().toString(), climbId) expect(userClimbTicks.length).toEqual(1) }) it('should delete all ticks with the specified userId', async () => { - const userId = 'abc123' const newTicks = await ticks.importTicks(testImport) if (newTicks == null) { fail('Should add 3 new ticks') } - await ticks.deleteAllTicks(userId) + await ticks.deleteAllTicks(userId.toUUID().toString()) const newTick = await tickModel.findOne({ userId }) expect(newTick).toBeNull() }) it('should only delete MP imports', async () => { - const userId = 'abc123' const MPTick = await ticks.addTick(toTest) const OBTick = await ticks.addTick(tickUpdate) @@ -167,7 +180,7 @@ describe('Ticks', () => { fail('Should add two new ticks') } - await ticks.deleteImportedTicks(userId) + await ticks.deleteImportedTicks(userId.toUUID().toString()) const newTick = await tickModel.findOne({ _id: OBTick._id }) expect(newTick?._id).toEqual(OBTick._id) expect(newTick?.notes).toEqual('Not sandbagged') diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 55ecd369..2af4a6e8 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -21,3 +21,6 @@ export function exhaustiveCheck (_value: never): never { export const geojsonPointToLongitude = (point: Point): number => point.coordinates[0] export const geojsonPointToLatitude = (point: Point): number => point.coordinates[1] + +export const NON_ALPHANUMERIC_REGEX = /[\W_\s]+/g +export const canonicalizeUsername = (username: string): string => username.replaceAll(NON_ALPHANUMERIC_REGEX, '')