diff --git a/packages/backend/MIGRATION_PLAN.md b/packages/backend/MIGRATION_PLAN.md index 6263295f..8b771681 100644 --- a/packages/backend/MIGRATION_PLAN.md +++ b/packages/backend/MIGRATION_PLAN.md @@ -45,7 +45,7 @@ Node.js HTTP Server --- -## Phase 2: REST API Reimplementation (TODO) +## Phase 2: REST API Reimplementation (IN PROGRESS) Reimplement Next.js REST APIs as GraphQL queries/mutations. Only endpoints that query our database - Aurora proxy routes stay in Next.js. @@ -53,8 +53,11 @@ Reimplement Next.js REST APIs as GraphQL queries/mutations. Only endpoints that | REST Endpoint | GraphQL Operation | Status | |---------------|-------------------|--------| -| `GET /api/v1/grades/[board_name]` | `Query.grades(boardName: String!)` | TODO | -| `GET /api/v1/angles/[board_name]/[layout_id]` | `Query.angles(boardName: String!, layoutId: Int!)` | TODO | +| `GET /api/v1/grades/[board_name]` | `Query.grades(boardName: String!)` | ✅ DONE | +| `GET /api/v1/angles/[board_name]/[layout_id]` | `Query.angles(boardName: String!, layoutId: Int!)` | ✅ DONE | +| N/A | `Query.layouts(boardName: String!)` | ✅ DONE | +| N/A | `Query.sizes(boardName: String!, layoutId: Int!)` | ✅ DONE | +| N/A | `Query.sets(boardName: String!, layoutId: Int!, sizeId: Int!)` | ✅ DONE | | `GET /api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/details` | `Query.boardDetails(...)` | TODO | **Source files:** @@ -66,8 +69,8 @@ Reimplement Next.js REST APIs as GraphQL queries/mutations. Only endpoints that | REST Endpoint | GraphQL Operation | Status | |---------------|-------------------|--------| -| `GET /api/v1/[board_name]/.../search` | `Query.searchClimbs(input: ClimbSearchInput!)` | TODO | -| `GET /api/v1/[board_name]/.../[climb_uuid]` | `Query.climb(...)` | TODO | +| `GET /api/v1/[board_name]/.../search` | `Query.searchClimbs(input: ClimbSearchInput!)` | ✅ DONE | +| `GET /api/v1/[board_name]/.../[climb_uuid]` | `Query.climb(...)` | ✅ DONE | **Medium Priority:** @@ -120,10 +123,14 @@ Reimplement Next.js REST APIs as GraphQL queries/mutations. Only endpoints that --- -## Phase 3: Type Sharing (TODO) +## Phase 3: Type Sharing (PARTIAL) Add new GraphQL types to `packages/shared-schema/src/schema.ts` and corresponding TypeScript types to `packages/shared-schema/src/types.ts`. +### Implemented Types +- `Grade`, `BoardAngle`, `Layout`, `Size`, `Set` - Board configuration types +- `ClimbSearchInput`, `ClimbSearchResult` - Climb search types + ### New Types Needed ```graphql @@ -176,10 +183,11 @@ type Favorite { climbUuid: String!, angle: Int! } - [x] Verify WebSocket subscriptions work - [x] Remove Express dependency -### Milestone 2: Core Queries (High Priority) -- [ ] Add new types to shared-schema -- [ ] Implement `grades`, `angles`, `boardDetails` queries -- [ ] Implement `searchClimbs`, `climb` queries +### Milestone 2: Core Queries (High Priority) - IN PROGRESS +- [x] Add new types to shared-schema +- [x] Implement `grades`, `angles`, `layouts`, `sizes`, `sets` queries +- [x] Implement `searchClimbs`, `climb` queries +- [ ] Implement `boardDetails` query (complex - requires hold/LED data) - [ ] Implement `profile` query and `updateProfile` mutation - [ ] Implement `auroraCredentials` queries/mutations diff --git a/packages/backend/src/db/tables.ts b/packages/backend/src/db/tables.ts new file mode 100644 index 00000000..d839fb99 --- /dev/null +++ b/packages/backend/src/db/tables.ts @@ -0,0 +1,91 @@ +// Table selection utility for backend +// Similar to packages/web/app/lib/db/queries/util/table-select.ts +import { + kilterClimbs, + kilterClimbStats, + kilterDifficultyGrades, + kilterProductSizes, + kilterLayouts, + kilterSets, + kilterProductSizesLayoutsSets, + kilterHoles, + kilterPlacements, + kilterLeds, + kilterProducts, + tensionClimbs, + tensionClimbStats, + tensionDifficultyGrades, + tensionProductSizes, + tensionLayouts, + tensionSets, + tensionProductSizesLayoutsSets, + tensionHoles, + tensionPlacements, + tensionLeds, + tensionProducts, +} from '@boardsesh/db/schema'; + +export type BoardName = 'kilter' | 'tension'; + +// Define the table structure +export type TableSet = { + climbs: typeof kilterClimbs | typeof tensionClimbs; + climbStats: typeof kilterClimbStats | typeof tensionClimbStats; + difficultyGrades: typeof kilterDifficultyGrades | typeof tensionDifficultyGrades; + productSizes: typeof kilterProductSizes | typeof tensionProductSizes; + layouts: typeof kilterLayouts | typeof tensionLayouts; + sets: typeof kilterSets | typeof tensionSets; + productSizesLayoutsSets: typeof kilterProductSizesLayoutsSets | typeof tensionProductSizesLayoutsSets; + holes: typeof kilterHoles | typeof tensionHoles; + placements: typeof kilterPlacements | typeof tensionPlacements; + leds: typeof kilterLeds | typeof tensionLeds; + products: typeof kilterProducts | typeof tensionProducts; +}; + +// Create a complete mapping of all tables +const BOARD_TABLES: Record = { + kilter: { + climbs: kilterClimbs, + climbStats: kilterClimbStats, + difficultyGrades: kilterDifficultyGrades, + productSizes: kilterProductSizes, + layouts: kilterLayouts, + sets: kilterSets, + productSizesLayoutsSets: kilterProductSizesLayoutsSets, + holes: kilterHoles, + placements: kilterPlacements, + leds: kilterLeds, + products: kilterProducts, + }, + tension: { + climbs: tensionClimbs, + climbStats: tensionClimbStats, + difficultyGrades: tensionDifficultyGrades, + productSizes: tensionProductSizes, + layouts: tensionLayouts, + sets: tensionSets, + productSizesLayoutsSets: tensionProductSizesLayoutsSets, + holes: tensionHoles, + placements: tensionPlacements, + leds: tensionLeds, + products: tensionProducts, + }, +} as const; + +/** + * Get all tables for a specific board + * @param boardName The board (kilter or tension) + * @returns All tables for the specified board + */ +export function getBoardTables(boardName: BoardName): TableSet { + return BOARD_TABLES[boardName]; +} + +/** + * Helper function to check if a board name is valid + * @param boardName The name to check + * @returns True if the board name is valid + */ +export function isValidBoardName(boardName: string): boardName is BoardName { + return boardName === 'kilter' || boardName === 'tension'; +} diff --git a/packages/backend/src/graphql/resolvers.ts b/packages/backend/src/graphql/resolvers.ts index ea03b2ed..7eca0391 100644 --- a/packages/backend/src/graphql/resolvers.ts +++ b/packages/backend/src/graphql/resolvers.ts @@ -1,6 +1,7 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import GraphQLJSON from 'graphql-type-json'; import { v4 as uuidv4 } from 'uuid'; +import { eq, and, sql, desc, SQL } from 'drizzle-orm'; import { typeDefs } from '@boardsesh/shared-schema'; import { roomManager, VersionConflictError, type DiscoverableSession } from '../services/room-manager.js'; import { pubsub } from '../pubsub/index.js'; @@ -27,8 +28,16 @@ import type { SessionEvent, ClimbQueueItem, QueueState, - SessionUser, + ClimbSearchInput, + Grade, + BoardAngle, + Layout, + Size, + Set, + Climb, } from '@boardsesh/shared-schema'; +import { db } from '../db/client.js'; +import { getBoardTables, isValidBoardName, type BoardName } from '../db/tables.js'; // Input type for createSession mutation type CreateSessionInput = { @@ -269,6 +278,304 @@ const resolvers = { distance: 0, // Not applicable for own sessions })); }, + + // Board Configuration Queries + grades: async (_: unknown, { boardName }: { boardName: string }): Promise => { + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}`); + } + const tables = getBoardTables(boardName); + const result = await db + .select({ + difficultyId: tables.difficultyGrades.difficulty, + difficultyName: tables.difficultyGrades.boulderName, + }) + .from(tables.difficultyGrades) + .where(eq(tables.difficultyGrades.isListed, true)) + .orderBy(tables.difficultyGrades.difficulty); + + return result.map((row) => ({ + difficultyId: row.difficultyId, + difficultyName: row.difficultyName || '', + })); + }, + + angles: async (_: unknown, { boardName, layoutId }: { boardName: string; layoutId: number }): Promise => { + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}`); + } + // Query the products_angles table joined with layouts + // The angles are associated with products, and layouts have product_ids + const result = await db.execute(sql` + SELECT DISTINCT pa.angle + FROM ${sql.identifier(boardName + '_products_angles')} pa + JOIN ${sql.identifier(boardName + '_layouts')} l ON l.product_id = pa.product_id + WHERE l.id = ${layoutId} + ORDER BY pa.angle ASC + `); + + return (result.rows as Array<{ angle: number }>).map((row) => ({ + angle: row.angle, + })); + }, + + layouts: async (_: unknown, { boardName }: { boardName: string }): Promise => { + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}`); + } + const tables = getBoardTables(boardName); + const result = await db + .select({ + id: tables.layouts.id, + name: tables.layouts.name, + }) + .from(tables.layouts) + .where(eq(tables.layouts.isListed, true)) + .orderBy(tables.layouts.id); + + return result.map((row) => ({ + id: row.id, + name: row.name || '', + })); + }, + + sizes: async (_: unknown, { boardName, layoutId }: { boardName: string; layoutId: number }): Promise => { + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}`); + } + const tables = getBoardTables(boardName); + // Get sizes that have product_sizes_layouts_sets entries for this layout + const result = await db.execute(sql` + SELECT DISTINCT ps.id, ps.name, ps.description + FROM ${sql.identifier(boardName + '_product_sizes')} ps + JOIN ${sql.identifier(boardName + '_product_sizes_layouts_sets')} psls ON psls.product_size_id = ps.id + WHERE psls.layout_id = ${layoutId} + AND ps.is_listed = true + ORDER BY ps.id ASC + `); + + return (result.rows as Array<{ id: number; name: string; description: string }>).map((row) => ({ + id: row.id, + name: row.name || '', + description: row.description || '', + })); + }, + + sets: async (_: unknown, { boardName, layoutId, sizeId }: { boardName: string; layoutId: number; sizeId: number }): Promise => { + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}`); + } + const tables = getBoardTables(boardName); + const result = await db + .select({ + id: tables.sets.id, + name: tables.sets.name, + }) + .from(tables.sets) + .innerJoin( + tables.productSizesLayoutsSets, + eq(tables.sets.id, tables.productSizesLayoutsSets.setId) + ) + .where( + and( + eq(tables.productSizesLayoutsSets.layoutId, layoutId), + eq(tables.productSizesLayoutsSets.productSizeId, sizeId) + ) + ) + .orderBy(tables.sets.id); + + return result.map((row) => ({ + id: row.id, + name: row.name || '', + })); + }, + + // Climb Queries + searchClimbs: async (_: unknown, { input }: { input: ClimbSearchInput }) => { + const { boardName, layoutId, sizeId, setIds, angle, page = 0, pageSize = 20 } = input; + + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}`); + } + + const tables = getBoardTables(boardName); + + // Build the where conditions + const whereConditions: SQL[] = [ + eq(tables.climbs.layoutId, layoutId), + eq(tables.climbs.isListed, true), + eq(tables.climbs.isDraft, false), + eq(tables.climbs.framesCount, 1), + eq(tables.climbStats.angle, angle), + ]; + + // Add filters if provided + if (input.minGrade !== undefined && input.minGrade !== null) { + whereConditions.push(sql`ROUND(${tables.climbStats.displayDifficulty}::numeric) >= ${input.minGrade}`); + } + if (input.maxGrade !== undefined && input.maxGrade !== null) { + whereConditions.push(sql`ROUND(${tables.climbStats.displayDifficulty}::numeric) <= ${input.maxGrade}`); + } + if (input.minAscents !== undefined && input.minAscents > 0) { + whereConditions.push(sql`${tables.climbStats.ascensionistCount} >= ${input.minAscents}`); + } + if (input.minRating !== undefined && input.minRating > 0) { + whereConditions.push(sql`${tables.climbStats.qualityAverage} >= ${input.minRating}`); + } + if (input.name) { + whereConditions.push(sql`LOWER(${tables.climbs.name}) LIKE LOWER(${`%${input.name}%`})`); + } + if (input.onlyClassics) { + whereConditions.push(sql`${tables.climbStats.benchmarkDifficulty} IS NOT NULL`); + } + + // Define sort columns + const sortColumns: Record = { + ascents: sql`${tables.climbStats.ascensionistCount}`, + difficulty: sql`ROUND(${tables.climbStats.displayDifficulty}::numeric, 0)`, + name: sql`${tables.climbs.name}`, + quality: sql`${tables.climbStats.qualityAverage}`, + }; + + const sortColumn = sortColumns[input.sortBy || 'ascents'] || sortColumns.ascents; + const sortOrder = input.sortOrder === 'asc' ? sql`ASC NULLS FIRST` : sql`DESC NULLS LAST`; + + // Execute search query + const searchResult = await db + .select({ + uuid: tables.climbs.uuid, + setter_username: tables.climbs.setterUsername, + name: tables.climbs.name, + description: tables.climbs.description, + frames: tables.climbs.frames, + angle: tables.climbStats.angle, + ascensionist_count: tables.climbStats.ascensionistCount, + difficulty: tables.difficultyGrades.boulderName, + quality_average: sql`ROUND(${tables.climbStats.qualityAverage}::numeric, 2)`, + difficulty_error: sql`ROUND(${tables.climbStats.difficultyAverage}::numeric - ${tables.climbStats.displayDifficulty}::numeric, 2)`, + benchmark_difficulty: tables.climbStats.benchmarkDifficulty, + }) + .from(tables.climbs) + .innerJoin(tables.climbStats, eq(tables.climbs.uuid, tables.climbStats.climbUuid)) + .leftJoin( + tables.difficultyGrades, + eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`) + ) + .where(and(...whereConditions)) + .orderBy(sql`${sortColumn} ${sortOrder}`, desc(tables.climbs.uuid)) + .limit(pageSize + 1) + .offset(page * pageSize); + + // Check if there are more results + const hasMore = searchResult.length > pageSize; + const trimmedResults = hasMore ? searchResult.slice(0, pageSize) : searchResult; + + // Execute count query for total + const countResult = await db + .select({ count: sql`count(*)` }) + .from(tables.climbs) + .innerJoin(tables.climbStats, eq(tables.climbs.uuid, tables.climbStats.climbUuid)) + .where(and(...whereConditions)); + + const totalCount = Number(countResult[0]?.count || 0); + + // Transform results to Climb type + const climbs = trimmedResults.map((row) => ({ + uuid: row.uuid, + setter_username: row.setter_username || '', + name: row.name || '', + description: row.description || '', + frames: row.frames || '', + angle: Number(row.angle) || angle, + ascensionist_count: Number(row.ascensionist_count) || 0, + difficulty: row.difficulty || '', + quality_average: row.quality_average?.toString() || '0', + stars: Math.round((Number(row.quality_average) || 0) * 5), + difficulty_error: row.difficulty_error?.toString() || '0', + benchmark_difficulty: row.benchmark_difficulty?.toString() || null, + litUpHoldsMap: {}, // Will be populated by client when needed + })); + + return { + climbs, + totalCount, + hasMore, + }; + }, + + climb: async ( + _: unknown, + { boardName, layoutId, sizeId, setIds, angle, climbUuid }: { + boardName: string; + layoutId: number; + sizeId: number; + setIds: number[]; + angle: number; + climbUuid: string; + } + ): Promise => { + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}`); + } + + const tables = getBoardTables(boardName); + + const result = await db + .select({ + uuid: tables.climbs.uuid, + setter_username: tables.climbs.setterUsername, + name: tables.climbs.name, + description: tables.climbs.description, + frames: tables.climbs.frames, + angle: tables.climbStats.angle, + ascensionist_count: tables.climbStats.ascensionistCount, + difficulty: tables.difficultyGrades.boulderName, + quality_average: sql`ROUND(${tables.climbStats.qualityAverage}::numeric, 2)`, + difficulty_error: sql`ROUND(${tables.climbStats.difficultyAverage}::numeric - ${tables.climbStats.displayDifficulty}::numeric, 2)`, + benchmark_difficulty: tables.climbStats.benchmarkDifficulty, + }) + .from(tables.climbs) + .leftJoin( + tables.climbStats, + and( + eq(tables.climbStats.climbUuid, tables.climbs.uuid), + eq(tables.climbStats.angle, angle) + ) + ) + .leftJoin( + tables.difficultyGrades, + eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`) + ) + .where( + and( + eq(tables.climbs.uuid, climbUuid), + eq(tables.climbs.layoutId, layoutId), + eq(tables.climbs.framesCount, 1) + ) + ) + .limit(1); + + if (result.length === 0) { + return null; + } + + const row = result[0]; + return { + uuid: row.uuid, + setter_username: row.setter_username || '', + name: row.name || '', + description: row.description || '', + frames: row.frames || '', + angle: Number(row.angle) || angle, + ascensionist_count: Number(row.ascensionist_count) || 0, + difficulty: row.difficulty || '', + quality_average: row.quality_average?.toString() || '0', + stars: Math.round((Number(row.quality_average) || 0) * 5), + difficulty_error: row.difficulty_error?.toString() || '0', + benchmark_difficulty: row.benchmark_difficulty?.toString() || null, + litUpHoldsMap: {}, // Will be populated by client when needed + }; + }, }, Mutation: { diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index c224ea74..8943b1d3 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -115,12 +115,85 @@ export const typeDefs = /* GraphQL */ ` discoverable: Boolean! } + # Board Configuration Types + type Grade { + difficultyId: Int! + difficultyName: String! + } + + type BoardAngle { + angle: Int! + } + + type Layout { + id: Int! + name: String! + } + + type Size { + id: Int! + name: String! + description: String! + } + + type Set { + id: Int! + name: String! + } + + # Climb Search Types + input ClimbSearchInput { + boardName: String! + layoutId: Int! + sizeId: Int! + setIds: [Int!]! + angle: Int! + # Pagination + page: Int + pageSize: Int + # Filters + minGrade: Int + maxGrade: Int + minAscents: Int + minRating: Int + gradeAccuracy: Int + name: String + settername: [String!] + onlyClassics: Boolean + onlyTallClimbs: Boolean + # Sort + sortBy: String + sortOrder: String + # Progress filters (requires userId) + hideAttempted: Boolean + hideCompleted: Boolean + showOnlyAttempted: Boolean + showOnlyCompleted: Boolean + } + + type ClimbSearchResult { + climbs: [Climb!]! + totalCount: Int! + hasMore: Boolean! + } + type Query { session(sessionId: ID!): Session # Find discoverable sessions near a location nearbySessions(latitude: Float!, longitude: Float!, radiusMeters: Float): [DiscoverableSession!]! # Get current user's recent sessions (requires auth context) mySessions: [DiscoverableSession!]! + + # Board Configuration Queries + grades(boardName: String!): [Grade!]! + angles(boardName: String!, layoutId: Int!): [BoardAngle!]! + layouts(boardName: String!): [Layout!]! + sizes(boardName: String!, layoutId: Int!): [Size!]! + sets(boardName: String!, layoutId: Int!, sizeId: Int!): [Set!]! + + # Climb Queries + searchClimbs(input: ClimbSearchInput!): ClimbSearchResult! + climb(boardName: String!, layoutId: Int!, sizeId: Int!, setIds: [Int!]!, angle: Int!, climbUuid: ID!): Climb } type Mutation { diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 199a493a..9d54c8e2 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -123,3 +123,67 @@ export type ConnectionContext = { userId?: string; isAuthenticated?: boolean; }; + +// Board Configuration Types +export type BoardName = 'kilter' | 'tension'; + +export type Grade = { + difficultyId: number; + difficultyName: string; +}; + +export type BoardAngle = { + angle: number; +}; + +export type Layout = { + id: number; + name: string; +}; + +export type Size = { + id: number; + name: string; + description: string; +}; + +export type Set = { + id: number; + name: string; +}; + +// Climb Search Types +export type ClimbSearchInput = { + boardName: string; + layoutId: number; + sizeId: number; + setIds: number[]; + angle: number; + // Pagination + page?: number; + pageSize?: number; + // Filters + minGrade?: number; + maxGrade?: number; + minAscents?: number; + minRating?: number; + gradeAccuracy?: number; + name?: string; + settername?: string[]; + onlyClassics?: boolean; + onlyTallClimbs?: boolean; + // Sort + sortBy?: string; + sortOrder?: string; + // Progress filters + hideAttempted?: boolean; + hideCompleted?: boolean; + showOnlyAttempted?: boolean; + showOnlyCompleted?: boolean; +}; + +export type ClimbSearchResult = { + climbs: Climb[]; + totalCount: number; + hasMore: boolean; +};