From 230a5a05568c325872266e42adeb192a5252cb3a Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 13 Feb 2026 15:32:45 +0100 Subject: [PATCH 1/3] Migrate Next.js API routes to backend GraphQL and remove dead code Delete dead Aurora proxy routes (blocked by App Attest), old sync cron, and shared-sync. Switch frontend callers for profile, credentials, controllers, and favorites to use existing GraphQL resolvers. Create new backend GraphQL resolvers for beta links, climb stats, hold classifications, user board mappings, unsynced counts, setter stats, and hold heatmap. Remove ~30 Next.js API route files and orphaned utilities, reducing the web package by ~3600 lines. Co-Authored-By: Claude Opus 4.6 --- .../db/queries/climbs/create-climb-filters.ts | 13 + .../resolvers/data-queries/mutations.ts | 162 +++++++ .../graphql/resolvers/data-queries/queries.ts | 456 ++++++++++++++++++ .../backend/src/graphql/resolvers/index.ts | 4 + .../src/graphql/resolvers/social/follows.ts | 2 + .../src/graphql/resolvers/users/mutations.ts | 16 +- .../src/graphql/resolvers/users/queries.ts | 4 + packages/backend/src/validation/schemas.ts | 80 ++- packages/shared-schema/src/schema.ts | 253 ++++++++++ packages/shared-schema/src/types.ts | 155 ++++++ .../aurora-credentials/unsynced/route.ts | 91 ---- .../web/app/api/internal/controllers/route.ts | 186 ------- .../web/app/api/internal/favorites/route.ts | 155 ------ .../__tests__/validation.test.ts | 146 ------ .../internal/hold-classifications/route.ts | 267 ---------- .../hold-classifications/validation.ts | 48 -- .../api/internal/profile/[userId]/route.ts | 104 ---- .../web/app/api/internal/profile/route.ts | 133 ----- .../shared-sync/[board_name]/route.ts | 109 ----- .../api/internal/user-board-mapping/route.ts | 66 --- .../app/api/internal/user-sync-cron/route.ts | 288 ----------- .../[set_ids]/[angle]/heatmap/route.ts | 116 ----- .../[set_ids]/[angle]/setters/route.ts | 31 -- .../[board_name]/beta/[climb_uuid]/route.ts | 44 -- .../climb-stats/[climb_uuid]/route.ts | 29 -- .../v1/[board_name]/proxy/getLogbook/route.ts | 59 --- .../api/v1/[board_name]/proxy/login/route.ts | 123 ----- .../v1/[board_name]/proxy/saveAscent/route.ts | 67 --- .../v1/[board_name]/proxy/user-sync/route.ts | 23 - .../components/beta-videos/beta-videos.tsx | 10 +- .../components/board-page/angle-selector.tsx | 19 +- .../climb-actions/actions/favorite-action.tsx | 22 +- .../climb-actions/favorite-button.tsx | 24 +- .../build-climb-detail-sections.tsx | 16 +- .../hold-classification-wizard.tsx | 88 ++-- .../party-manager/party-profile-context.tsx | 40 +- .../play-view/play-view-beta-slider.tsx | 23 +- .../search-drawer/setter-name-select.tsx | 43 +- .../components/search-drawer/use-heatmap.tsx | 61 ++- .../settings/aurora-credentials-section.tsx | 54 ++- .../settings/controllers-section.tsx | 85 ++-- .../[user_id]/profile-page-content.tsx | 59 ++- .../web/app/lib/api-docs/openapi-registry.ts | 86 ---- .../web/app/lib/api-docs/openapi-routes.ts | 189 -------- .../lib/data-sync/aurora/convert-quality.ts | 11 - .../app/lib/data-sync/aurora/shared-sync.ts | 417 ---------------- .../web/app/lib/data-sync/aurora/user-sync.ts | 9 +- .../web/app/lib/db/queries/climbs/Untitled | 0 .../db/queries/climbs/create-climb-filters.ts | 305 ------------ .../lib/db/queries/climbs/holds-heatmap.ts | 180 ------- .../app/lib/db/queries/climbs/setter-stats.ts | 64 --- .../lib/graphql/operations/data-queries.ts | 277 +++++++++++ .../web/app/lib/graphql/operations/index.ts | 2 + .../web/app/lib/graphql/operations/profile.ts | 167 +++++++ .../web/app/lib/graphql/operations/social.ts | 1 + packages/web/app/lib/url-utils.ts | 8 - .../app/settings/settings-page-content.tsx | 68 ++- vercel.json | 19 +- 58 files changed, 1949 insertions(+), 3628 deletions(-) create mode 100644 packages/backend/src/graphql/resolvers/data-queries/mutations.ts create mode 100644 packages/backend/src/graphql/resolvers/data-queries/queries.ts delete mode 100644 packages/web/app/api/internal/aurora-credentials/unsynced/route.ts delete mode 100644 packages/web/app/api/internal/controllers/route.ts delete mode 100644 packages/web/app/api/internal/favorites/route.ts delete mode 100644 packages/web/app/api/internal/hold-classifications/__tests__/validation.test.ts delete mode 100644 packages/web/app/api/internal/hold-classifications/route.ts delete mode 100644 packages/web/app/api/internal/hold-classifications/validation.ts delete mode 100644 packages/web/app/api/internal/profile/[userId]/route.ts delete mode 100644 packages/web/app/api/internal/profile/route.ts delete mode 100644 packages/web/app/api/internal/shared-sync/[board_name]/route.ts delete mode 100644 packages/web/app/api/internal/user-board-mapping/route.ts delete mode 100644 packages/web/app/api/internal/user-sync-cron/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/beta/[climb_uuid]/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/climb-stats/[climb_uuid]/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/proxy/getLogbook/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/proxy/login/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/proxy/saveAscent/route.ts delete mode 100644 packages/web/app/api/v1/[board_name]/proxy/user-sync/route.ts delete mode 100644 packages/web/app/lib/data-sync/aurora/convert-quality.ts delete mode 100644 packages/web/app/lib/data-sync/aurora/shared-sync.ts delete mode 100644 packages/web/app/lib/db/queries/climbs/Untitled delete mode 100644 packages/web/app/lib/db/queries/climbs/create-climb-filters.ts delete mode 100644 packages/web/app/lib/db/queries/climbs/holds-heatmap.ts delete mode 100644 packages/web/app/lib/db/queries/climbs/setter-stats.ts create mode 100644 packages/web/app/lib/graphql/operations/data-queries.ts create mode 100644 packages/web/app/lib/graphql/operations/profile.ts diff --git a/packages/backend/src/db/queries/climbs/create-climb-filters.ts b/packages/backend/src/db/queries/climbs/create-climb-filters.ts index dca76660..e076395f 100644 --- a/packages/backend/src/db/queries/climbs/create-climb-filters.ts +++ b/packages/backend/src/db/queries/climbs/create-climb-filters.ts @@ -251,6 +251,19 @@ export const createClimbFilters = ( eq(tables.climbStats.angle, params.angle), ], + // For use in getHoldHeatmapData - joins climbStats via climbHolds + getHoldHeatmapClimbStatsConditions: () => [ + eq(tables.climbStats.climbUuid, tables.climbHolds.climbUuid), + eq(tables.climbStats.boardType, params.board_name), + eq(tables.climbStats.angle, params.angle), + ], + + // For use when joining climbHolds to climbs + getClimbHoldsJoinConditions: () => [ + eq(tables.climbHolds.climbUuid, tables.climbs.uuid), + eq(tables.climbHolds.boardType, params.board_name), + ], + // User-specific logbook data selectors getUserLogbookSelects, diff --git a/packages/backend/src/graphql/resolvers/data-queries/mutations.ts b/packages/backend/src/graphql/resolvers/data-queries/mutations.ts new file mode 100644 index 00000000..4e6a1167 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/data-queries/mutations.ts @@ -0,0 +1,162 @@ +import { eq, and } from 'drizzle-orm'; +import type { ConnectionContext } from '@boardsesh/shared-schema'; +import { db } from '../../../db/client'; +import * as dbSchema from '@boardsesh/db/schema'; +import { requireAuthenticated, validateInput } from '../shared/helpers'; +import { + SaveHoldClassificationInputSchema, + SaveUserBoardMappingInputSchema, +} from '../../../validation/schemas'; + +export const dataQueryMutations = { + /** + * Save or update a hold classification. + * Requires authentication. + */ + saveHoldClassification: async ( + _: unknown, + { input }: { input: { + boardType: string; + layoutId: number; + sizeId: number; + holdId: number; + holdType?: string | null; + handRating?: number | null; + footRating?: number | null; + pullDirection?: number | null; + }}, + ctx: ConnectionContext, + ) => { + requireAuthenticated(ctx); + const validatedInput = validateInput(SaveHoldClassificationInputSchema, input, 'input'); + const userId = ctx.userId!; + + // Check if a classification already exists + const existing = await db + .select() + .from(dbSchema.userHoldClassifications) + .where( + and( + eq(dbSchema.userHoldClassifications.userId, userId), + eq(dbSchema.userHoldClassifications.boardType, validatedInput.boardType), + eq(dbSchema.userHoldClassifications.layoutId, validatedInput.layoutId), + eq(dbSchema.userHoldClassifications.sizeId, validatedInput.sizeId), + eq(dbSchema.userHoldClassifications.holdId, validatedInput.holdId), + ), + ) + .limit(1); + + const now = new Date().toISOString(); + + if (existing.length > 0) { + // Update existing classification + await db + .update(dbSchema.userHoldClassifications) + .set({ + holdType: validatedInput.holdType ?? null, + handRating: validatedInput.handRating ?? null, + footRating: validatedInput.footRating ?? null, + pullDirection: validatedInput.pullDirection ?? null, + updatedAt: now, + }) + .where(eq(dbSchema.userHoldClassifications.id, existing[0].id)); + + return { + id: existing[0].id.toString(), + userId, + boardType: validatedInput.boardType, + layoutId: validatedInput.layoutId, + sizeId: validatedInput.sizeId, + holdId: validatedInput.holdId, + holdType: validatedInput.holdType ?? null, + handRating: validatedInput.handRating ?? null, + footRating: validatedInput.footRating ?? null, + pullDirection: validatedInput.pullDirection ?? null, + createdAt: existing[0].createdAt, + updatedAt: now, + }; + } else { + // Create new classification + const [result] = await db + .insert(dbSchema.userHoldClassifications) + .values({ + userId, + boardType: validatedInput.boardType, + layoutId: validatedInput.layoutId, + sizeId: validatedInput.sizeId, + holdId: validatedInput.holdId, + holdType: validatedInput.holdType ?? null, + handRating: validatedInput.handRating ?? null, + footRating: validatedInput.footRating ?? null, + pullDirection: validatedInput.pullDirection ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(); + + return { + id: result.id.toString(), + userId, + boardType: validatedInput.boardType, + layoutId: validatedInput.layoutId, + sizeId: validatedInput.sizeId, + holdId: validatedInput.holdId, + holdType: validatedInput.holdType ?? null, + handRating: validatedInput.handRating ?? null, + footRating: validatedInput.footRating ?? null, + pullDirection: validatedInput.pullDirection ?? null, + createdAt: now, + updatedAt: now, + }; + } + }, + + /** + * Save a user board mapping. + * Requires authentication. + */ + saveUserBoardMapping: async ( + _: unknown, + { input }: { input: { boardType: string; boardUserId: number; boardUsername?: string | null } }, + ctx: ConnectionContext, + ) => { + requireAuthenticated(ctx); + const validatedInput = validateInput(SaveUserBoardMappingInputSchema, input, 'input'); + const userId = ctx.userId!; + + // Upsert: check if mapping already exists + const existing = await db + .select() + .from(dbSchema.userBoardMappings) + .where( + and( + eq(dbSchema.userBoardMappings.userId, userId), + eq(dbSchema.userBoardMappings.boardType, validatedInput.boardType), + eq(dbSchema.userBoardMappings.boardUserId, validatedInput.boardUserId), + ), + ) + .limit(1); + + if (existing.length > 0) { + // Update existing mapping + await db + .update(dbSchema.userBoardMappings) + .set({ + boardUsername: validatedInput.boardUsername ?? null, + }) + .where(eq(dbSchema.userBoardMappings.id, existing[0].id)); + } else { + // Create new mapping + await db + .insert(dbSchema.userBoardMappings) + .values({ + userId, + boardType: validatedInput.boardType, + boardUserId: validatedInput.boardUserId, + boardUsername: validatedInput.boardUsername ?? null, + }); + } + + return true; + }, +}; diff --git a/packages/backend/src/graphql/resolvers/data-queries/queries.ts b/packages/backend/src/graphql/resolvers/data-queries/queries.ts new file mode 100644 index 00000000..d491137d --- /dev/null +++ b/packages/backend/src/graphql/resolvers/data-queries/queries.ts @@ -0,0 +1,456 @@ +import { eq, and, sql, isNull, count, ilike } from 'drizzle-orm'; +import type { ConnectionContext, SetterStatsInput, HoldHeatmapInput, BoardName } from '@boardsesh/shared-schema'; +import { db } from '../../../db/client'; +import * as dbSchema from '@boardsesh/db/schema'; +import { requireAuthenticated, validateInput } from '../shared/helpers'; +import { + BoardNameSchema, + ExternalUUIDSchema, + GetHoldClassificationsInputSchema, + SetterStatsInputSchema, + HoldHeatmapInputSchema, +} from '../../../validation/schemas'; +import { UNIFIED_TABLES } from '../../../db/queries/util/table-select'; +import { getSizeEdges } from '../../../db/queries/util/product-sizes-data'; +import { createClimbFilters } from '../../../db/queries/climbs/create-climb-filters'; + +export const dataQueryQueries = { + /** + * Get beta video links for a climb. + * No authentication required. + */ + betaLinks: async ( + _: unknown, + { boardName, climbUuid }: { boardName: string; climbUuid: string }, + _ctx: ConnectionContext, + ) => { + validateInput(BoardNameSchema, boardName, 'boardName'); + validateInput(ExternalUUIDSchema, climbUuid, 'climbUuid'); + + const results = await db + .select() + .from(dbSchema.boardBetaLinks) + .where( + and( + eq(dbSchema.boardBetaLinks.boardType, boardName), + eq(dbSchema.boardBetaLinks.climbUuid, climbUuid), + ), + ); + + return results.map((link) => ({ + climbUuid: link.climbUuid, + link: link.link, + foreignUsername: link.foreignUsername, + angle: link.angle, + thumbnail: link.thumbnail, + isListed: link.isListed, + createdAt: link.createdAt, + })); + }, + + /** + * Get climb statistics across all angles. + * No authentication required. + */ + climbStatsForAllAngles: async ( + _: unknown, + { boardName, climbUuid }: { boardName: string; climbUuid: string }, + _ctx: ConnectionContext, + ) => { + validateInput(BoardNameSchema, boardName, 'boardName'); + validateInput(ExternalUUIDSchema, climbUuid, 'climbUuid'); + + const result = await db.execute(sql` + SELECT + climb_stats.angle, + COALESCE(climb_stats.ascensionist_count, 0) as ascensionist_count, + ROUND(climb_stats.quality_average::numeric, 2) as quality_average, + climb_stats.difficulty_average, + climb_stats.display_difficulty, + climb_stats.fa_username, + climb_stats.fa_at, + dg.boulder_name as difficulty + FROM board_climb_stats climb_stats + LEFT JOIN board_difficulty_grades dg + ON dg.difficulty = ROUND(climb_stats.display_difficulty::numeric) + AND dg.board_type = ${boardName} + WHERE climb_stats.board_type = ${boardName} + AND climb_stats.climb_uuid = ${climbUuid} + ORDER BY climb_stats.angle ASC + `); + + const rows = Array.isArray(result) ? result : (result as { rows: Record[] }).rows ?? result; + + return (rows as Record[]).map((row) => ({ + angle: Number(row.angle), + ascensionistCount: Number(row.ascensionist_count || 0), + qualityAverage: row.quality_average as string | null, + difficultyAverage: row.difficulty_average != null ? Number(row.difficulty_average) : null, + displayDifficulty: row.display_difficulty != null ? Number(row.display_difficulty) : null, + faUsername: row.fa_username as string | null, + faAt: row.fa_at as string | null, + difficulty: row.difficulty as string | null, + })); + }, + + /** + * Get hold classifications for the current user and board configuration. + * Requires authentication. + */ + holdClassifications: async ( + _: unknown, + { input }: { input: { boardType: string; layoutId: number; sizeId: number } }, + ctx: ConnectionContext, + ) => { + requireAuthenticated(ctx); + const validatedInput = validateInput(GetHoldClassificationsInputSchema, input, 'input'); + const userId = ctx.userId!; + + const classifications = await db + .select() + .from(dbSchema.userHoldClassifications) + .where( + and( + eq(dbSchema.userHoldClassifications.userId, userId), + eq(dbSchema.userHoldClassifications.boardType, validatedInput.boardType), + eq(dbSchema.userHoldClassifications.layoutId, validatedInput.layoutId), + eq(dbSchema.userHoldClassifications.sizeId, validatedInput.sizeId), + ), + ); + + return classifications.map((c) => ({ + id: c.id.toString(), + userId: c.userId, + boardType: c.boardType, + layoutId: c.layoutId, + sizeId: c.sizeId, + holdId: c.holdId, + holdType: c.holdType, + handRating: c.handRating, + footRating: c.footRating, + pullDirection: c.pullDirection, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + })); + }, + + /** + * Get user board mappings for the current user. + * Requires authentication. + */ + userBoardMappings: async ( + _: unknown, + _args: Record, + ctx: ConnectionContext, + ) => { + requireAuthenticated(ctx); + const userId = ctx.userId!; + + const mappings = await db + .select() + .from(dbSchema.userBoardMappings) + .where(eq(dbSchema.userBoardMappings.userId, userId)); + + return mappings.map((m) => ({ + id: m.id.toString(), + userId: m.userId, + boardType: m.boardType, + boardUserId: m.boardUserId, + boardUsername: m.boardUsername, + createdAt: m.linkedAt?.toISOString() ?? null, + })); + }, + + /** + * Get count of unsynced items for the current user's Aurora accounts. + * Requires authentication. + */ + unsyncedCounts: async ( + _: unknown, + _args: Record, + ctx: ConnectionContext, + ) => { + requireAuthenticated(ctx); + const userId = ctx.userId!; + + // Get user's Aurora account user IDs from credentials + const credentials = await db + .select({ + boardType: dbSchema.auroraCredentials.boardType, + auroraUserId: dbSchema.auroraCredentials.auroraUserId, + }) + .from(dbSchema.auroraCredentials) + .where(eq(dbSchema.auroraCredentials.userId, userId)); + + const counts = { + kilter: { ascents: 0, climbs: 0 }, + tension: { ascents: 0, climbs: 0 }, + }; + + for (const cred of credentials) { + if (!cred.auroraUserId) continue; + + const boardType = cred.boardType as 'kilter' | 'tension'; + + // Count unsynced ticks (ascents/bids) - those without an auroraId + const [ascentResult] = await db + .select({ count: count() }) + .from(dbSchema.boardseshTicks) + .where( + and( + eq(dbSchema.boardseshTicks.userId, userId), + eq(dbSchema.boardseshTicks.boardType, boardType), + isNull(dbSchema.boardseshTicks.auroraId), + ), + ); + + // Count unsynced climbs for this user + const [climbResult] = await db + .select({ count: count() }) + .from(dbSchema.boardClimbs) + .where( + and( + eq(dbSchema.boardClimbs.boardType, boardType), + eq(dbSchema.boardClimbs.setterId, cred.auroraUserId), + eq(dbSchema.boardClimbs.synced, false), + ), + ); + + if (boardType === 'kilter') { + counts.kilter.ascents = ascentResult?.count ?? 0; + counts.kilter.climbs = climbResult?.count ?? 0; + } else if (boardType === 'tension') { + counts.tension.ascents = ascentResult?.count ?? 0; + counts.tension.climbs = climbResult?.count ?? 0; + } + } + + return counts; + }, + + /** + * Get setter statistics for a board configuration. + * No authentication required. + */ + setterStats: async ( + _: unknown, + { input }: { input: SetterStatsInput }, + _ctx: ConnectionContext, + ) => { + const validatedInput = validateInput(SetterStatsInputSchema, input, 'input'); + const { climbs, climbStats } = UNIFIED_TABLES; + + // MoonBoard doesn't have setter stats + if (validatedInput.boardName === 'moonboard') { + return []; + } + + const sizeEdges = getSizeEdges(validatedInput.boardName as BoardName, validatedInput.sizeId); + if (!sizeEdges) { + return []; + } + + const whereConditions = [ + eq(climbs.boardType, validatedInput.boardName), + eq(climbs.layoutId, validatedInput.layoutId), + eq(climbStats.angle, validatedInput.angle), + sql`${climbs.edgeLeft} > ${sizeEdges.edgeLeft}`, + sql`${climbs.edgeRight} < ${sizeEdges.edgeRight}`, + sql`${climbs.edgeBottom} > ${sizeEdges.edgeBottom}`, + sql`${climbs.edgeTop} < ${sizeEdges.edgeTop}`, + sql`${climbs.setterUsername} IS NOT NULL`, + sql`${climbs.setterUsername} != ''`, + ]; + + if (validatedInput.search && validatedInput.search.trim().length > 0) { + whereConditions.push(ilike(climbs.setterUsername, `%${validatedInput.search}%`)); + } + + const result = await db + .select({ + setter_username: climbs.setterUsername, + climb_count: sql`count(*)::int`, + }) + .from(climbs) + .innerJoin(climbStats, and( + eq(climbStats.climbUuid, climbs.uuid), + eq(climbStats.boardType, validatedInput.boardName), + )) + .where(and(...whereConditions)) + .groupBy(climbs.setterUsername) + .orderBy(sql`count(*) DESC`) + .limit(50); + + return result + .filter((stat) => stat.setter_username !== null) + .map((stat) => ({ + setterUsername: stat.setter_username, + climbCount: stat.climb_count, + })); + }, + + /** + * Get hold heatmap data for a board configuration. + * Optional authentication for user-specific data. + */ + holdHeatmap: async ( + _: unknown, + { input }: { input: HoldHeatmapInput }, + ctx: ConnectionContext, + ) => { + const validatedInput = validateInput(HoldHeatmapInputSchema, input, 'input'); + const { climbs, climbStats, climbHolds } = UNIFIED_TABLES; + + // MoonBoard doesn't have heatmap data + if (validatedInput.boardName === 'moonboard') { + return []; + } + + const sizeEdges = getSizeEdges(validatedInput.boardName as BoardName, validatedInput.sizeId); + if (!sizeEdges) { + return []; + } + + const setIds = validatedInput.setIds.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !isNaN(id)); + const userId = ctx.userId ?? undefined; + + const params = { + board_name: validatedInput.boardName as BoardName, + layout_id: validatedInput.layoutId, + size_id: validatedInput.sizeId, + set_ids: setIds, + angle: validatedInput.angle, + }; + + const searchParams = { + gradeAccuracy: validatedInput.gradeAccuracy ? parseFloat(validatedInput.gradeAccuracy) : undefined, + minGrade: validatedInput.minGrade ?? undefined, + maxGrade: validatedInput.maxGrade ?? undefined, + minAscents: validatedInput.minAscents ?? undefined, + minRating: validatedInput.minRating ?? undefined, + sortBy: validatedInput.sortBy ?? undefined, + sortOrder: validatedInput.sortOrder ?? undefined, + name: validatedInput.name ?? undefined, + settername: validatedInput.settername ?? undefined, + onlyClassics: validatedInput.onlyClassics ?? undefined, + onlyTallClimbs: validatedInput.onlyTallClimbs ?? undefined, + holdsFilter: validatedInput.holdsFilter as Record | undefined, + hideAttempted: validatedInput.hideAttempted ?? undefined, + hideCompleted: validatedInput.hideCompleted ?? undefined, + showOnlyAttempted: validatedInput.showOnlyAttempted ?? undefined, + showOnlyCompleted: validatedInput.showOnlyCompleted ?? undefined, + }; + + const filters = createClimbFilters(UNIFIED_TABLES, params, searchParams, sizeEdges, userId); + + const personalProgressFiltersEnabled = + searchParams.hideAttempted || + searchParams.hideCompleted || + searchParams.showOnlyAttempted || + searchParams.showOnlyCompleted; + + let holdStats: Record[]; + + // Both paths share the same query structure, just differ in totalAscents calculation + const baseSelect = { + holdId: climbHolds.holdId, + totalUses: sql`COUNT(DISTINCT ${climbHolds.climbUuid})`, + startingUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'STARTING' THEN 1 ELSE 0 END)`, + handUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'HAND' THEN 1 ELSE 0 END)`, + footUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'FOOT' THEN 1 ELSE 0 END)`, + finishUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'FINISH' THEN 1 ELSE 0 END)`, + averageDifficulty: sql`AVG(${climbStats.displayDifficulty})`, + }; + + if (personalProgressFiltersEnabled && userId) { + holdStats = await db + .select({ + ...baseSelect, + totalAscents: sql`COUNT(DISTINCT ${climbHolds.climbUuid})`, + }) + .from(climbHolds) + .innerJoin(climbs, and(...filters.getClimbHoldsJoinConditions())) + .leftJoin(climbStats, and(...filters.getHoldHeatmapClimbStatsConditions())) + .where( + and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()), + ) + .groupBy(climbHolds.holdId); + } else { + holdStats = await db + .select({ + ...baseSelect, + totalAscents: sql`SUM(${climbStats.ascensionistCount})`, + }) + .from(climbHolds) + .innerJoin(climbs, and(...filters.getClimbHoldsJoinConditions())) + .leftJoin(climbStats, and(...filters.getHoldHeatmapClimbStatsConditions())) + .where( + and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()), + ) + .groupBy(climbHolds.holdId); + } + + // Add user-specific data + if (userId && !personalProgressFiltersEnabled) { + const [userAscentsQuery, userAttemptsQuery] = await Promise.all([ + db.execute(sql` + SELECT ch.hold_id, COUNT(*) as user_ascents + FROM ${dbSchema.boardseshTicks} t + JOIN board_climb_holds ch ON t.climb_uuid = ch.climb_uuid AND ch.board_type = ${validatedInput.boardName} + WHERE t.user_id = ${userId} + AND t.board_type = ${validatedInput.boardName} + AND t.angle = ${validatedInput.angle} + AND t.status IN ('flash', 'send') + GROUP BY ch.hold_id + `), + db.execute(sql` + SELECT ch.hold_id, SUM(t.attempt_count) as user_attempts + FROM ${dbSchema.boardseshTicks} t + JOIN board_climb_holds ch ON t.climb_uuid = ch.climb_uuid AND ch.board_type = ${validatedInput.boardName} + WHERE t.user_id = ${userId} + AND t.board_type = ${validatedInput.boardName} + AND t.angle = ${validatedInput.angle} + GROUP BY ch.hold_id + `), + ]); + + const ascentsRows = Array.isArray(userAscentsQuery) ? userAscentsQuery : (userAscentsQuery as { rows: Record[] }).rows ?? userAscentsQuery; + const attemptsRows = Array.isArray(userAttemptsQuery) ? userAttemptsQuery : (userAttemptsQuery as { rows: Record[] }).rows ?? userAttemptsQuery; + + const ascentsMap = new Map(); + const attemptsMap = new Map(); + + for (const row of ascentsRows as Record[]) { + ascentsMap.set(Number(row.hold_id), Number(row.user_ascents)); + } + for (const row of attemptsRows as Record[]) { + attemptsMap.set(Number(row.hold_id), Number(row.user_attempts)); + } + + holdStats = holdStats.map((stat) => ({ + ...stat, + userAscents: ascentsMap.get(Number(stat.holdId)) || 0, + userAttempts: attemptsMap.get(Number(stat.holdId)) || 0, + })); + } else if (personalProgressFiltersEnabled && userId) { + holdStats = holdStats.map((stat) => ({ + ...stat, + userAscents: Number(stat.totalAscents) || 0, + userAttempts: Number(stat.totalUses) || 0, + })); + } + + return holdStats.map((stats) => ({ + holdId: Number(stats.holdId), + totalUses: Number(stats.totalUses || 0), + startingUses: Number(stats.startingUses || 0), + totalAscents: Number(stats.totalAscents || 0), + handUses: Number(stats.handUses || 0), + footUses: Number(stats.footUses || 0), + finishUses: Number(stats.finishUses || 0), + averageDifficulty: stats.averageDifficulty ? Number(stats.averageDifficulty) : null, + userAscents: userId ? Number(stats.userAscents || 0) : null, + userAttempts: userId ? Number(stats.userAttempts || 0) : null, + })); + }, +}; diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index 91c8f554..862f5c61 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -39,6 +39,8 @@ import { socialRoleQueries, socialRoleMutations } from './social/roles'; import { socialCommunitySettingsQueries, socialCommunitySettingsMutations } from './social/community-settings'; import { newClimbSubscriptionResolvers } from './social/new-climb-subscriptions'; import { newClimbFeedSubscription } from './social/new-climb-feed-subscription'; +import { dataQueryQueries } from './data-queries/queries'; +import { dataQueryMutations } from './data-queries/mutations'; export const resolvers = { // Scalar types @@ -68,6 +70,7 @@ export const resolvers = { ...socialRoleQueries, ...socialCommunitySettingsQueries, ...newClimbSubscriptionResolvers.Query, + ...dataQueryQueries, }, Mutation: { @@ -89,6 +92,7 @@ export const resolvers = { ...socialRoleMutations, ...socialCommunitySettingsMutations, ...newClimbSubscriptionResolvers.Mutation, + ...dataQueryMutations, }, Subscription: { diff --git a/packages/backend/src/graphql/resolvers/social/follows.ts b/packages/backend/src/graphql/resolvers/social/follows.ts index 4e0c3013..a6a6af6a 100644 --- a/packages/backend/src/graphql/resolvers/social/follows.ts +++ b/packages/backend/src/graphql/resolvers/social/follows.ts @@ -176,6 +176,7 @@ export const socialFollowQueries = { image: dbSchema.users.image, displayName: dbSchema.userProfiles.displayName, avatarUrl: dbSchema.userProfiles.avatarUrl, + instagramUrl: dbSchema.userProfiles.instagramUrl, }) .from(dbSchema.users) .leftJoin(dbSchema.userProfiles, eq(dbSchema.users.id, dbSchema.userProfiles.userId)) @@ -199,6 +200,7 @@ export const socialFollowQueries = { id: user.id, displayName: user.displayName || user.name || undefined, avatarUrl: user.avatarUrl || user.image || undefined, + instagramUrl: user.instagramUrl || undefined, followerCount: enrichment?.followerCount ?? 0, followingCount: enrichment?.followingCount ?? 0, isFollowedByMe: enrichment?.isFollowedByMe ?? false, diff --git a/packages/backend/src/graphql/resolvers/users/mutations.ts b/packages/backend/src/graphql/resolvers/users/mutations.ts index 063c2186..4ac1542c 100644 --- a/packages/backend/src/graphql/resolvers/users/mutations.ts +++ b/packages/backend/src/graphql/resolvers/users/mutations.ts @@ -12,7 +12,7 @@ export const userMutations = { */ updateProfile: async ( _: unknown, - { input }: { input: { displayName?: string; avatarUrl?: string } }, + { input }: { input: { displayName?: string; avatarUrl?: string; instagramUrl?: string } }, ctx: ConnectionContext ): Promise => { requireAuthenticated(ctx); @@ -33,6 +33,7 @@ export const userMutations = { userId, displayName: input.displayName, avatarUrl: input.avatarUrl, + instagramUrl: input.instagramUrl, }); } else { // Update existing profile @@ -41,10 +42,22 @@ export const userMutations = { .set({ displayName: input.displayName ?? existingProfile[0].displayName, avatarUrl: input.avatarUrl ?? existingProfile[0].avatarUrl, + instagramUrl: input.instagramUrl ?? existingProfile[0].instagramUrl, }) .where(eq(dbSchema.userProfiles.userId, userId)); } + // Also update the user's name if displayName is provided + if (input.displayName !== undefined) { + await db + .update(dbSchema.users) + .set({ + name: input.displayName || null, + updatedAt: new Date(), + }) + .where(eq(dbSchema.users.id, userId)); + } + // Fetch and return updated profile const users = await db .select() @@ -66,6 +79,7 @@ export const userMutations = { email: user.email, displayName: profile?.displayName || user.name || undefined, avatarUrl: profile?.avatarUrl || user.image || undefined, + instagramUrl: profile?.instagramUrl || undefined, }; }, diff --git a/packages/backend/src/graphql/resolvers/users/queries.ts b/packages/backend/src/graphql/resolvers/users/queries.ts index f672f2d7..7aa31f13 100644 --- a/packages/backend/src/graphql/resolvers/users/queries.ts +++ b/packages/backend/src/graphql/resolvers/users/queries.ts @@ -40,6 +40,7 @@ export const userQueries = { email: user.email, displayName: profile?.displayName || user.name || undefined, avatarUrl: profile?.avatarUrl || user.image || undefined, + instagramUrl: profile?.instagramUrl || undefined, }; }, @@ -62,6 +63,9 @@ export const userQueries = { userId: c.auroraUserId || undefined, syncedAt: c.lastSyncAt?.toISOString() || undefined, hasToken: !!c.auroraToken, + syncStatus: c.syncStatus || undefined, + syncError: c.syncError || undefined, + createdAt: c.createdAt?.toISOString() || undefined, })); }, diff --git a/packages/backend/src/validation/schemas.ts b/packages/backend/src/validation/schemas.ts index c31ecf17..bd35d0c6 100644 --- a/packages/backend/src/validation/schemas.ts +++ b/packages/backend/src/validation/schemas.ts @@ -224,7 +224,8 @@ export const ClimbSearchInputSchema = z.object({ */ export const UpdateProfileInputSchema = z.object({ displayName: z.string().min(1).max(100).optional(), - avatarUrl: z.string().url().max(500).optional(), + avatarUrl: z.string().url().max(500).optional().nullable(), + instagramUrl: z.string().url().max(500).optional().nullable(), }); /** @@ -951,6 +952,83 @@ export const LinkBoardToGymInputSchema = z.object({ gymUuid: UUIDSchema.optional().nullable(), }); +// ============================================ +// Data Query Schemas (migrated from Next.js) +// ============================================ + +/** + * Get hold classifications input validation schema + */ +export const GetHoldClassificationsInputSchema = z.object({ + boardType: BoardNameSchema, + layoutId: z.number().int().positive('Layout ID must be positive'), + sizeId: z.number().int().positive('Size ID must be positive'), +}); + +/** + * Save hold classification input validation schema + */ +export const SaveHoldClassificationInputSchema = z.object({ + boardType: BoardNameSchema, + layoutId: z.number().int().positive('Layout ID must be positive'), + sizeId: z.number().int().positive('Size ID must be positive'), + holdId: z.number().int().positive('Hold ID must be positive'), + holdType: z.enum(['jug', 'sloper', 'pinch', 'crimp', 'pocket']).optional().nullable(), + handRating: z.number().int().min(1).max(5).optional().nullable(), + footRating: z.number().int().min(1).max(5).optional().nullable(), + pullDirection: z.number().int().min(0).max(360).optional().nullable(), +}); + +/** + * Save user board mapping input validation schema + */ +export const SaveUserBoardMappingInputSchema = z.object({ + boardType: z.enum(['kilter', 'tension'], { + errorMap: () => ({ message: 'Board type must be kilter or tension' }), + }), + boardUserId: z.number().int().positive('Board user ID must be a positive integer'), + boardUsername: z.string().max(100, 'Username too long').optional().nullable(), +}); + +/** + * Setter stats input validation schema + */ +export const SetterStatsInputSchema = z.object({ + boardName: BoardNameSchema, + layoutId: z.number().int().positive(), + sizeId: z.number().int().positive(), + setIds: z.string().min(1), + angle: z.number().int(), + search: z.string().max(200).optional().nullable(), +}); + +/** + * Hold heatmap input validation schema + */ +export const HoldHeatmapInputSchema = z.object({ + boardName: BoardNameSchema, + layoutId: z.number().int().positive(), + sizeId: z.number().int().positive(), + setIds: z.string().min(1), + angle: z.number().int(), + gradeAccuracy: z.string().optional().nullable(), + minGrade: z.number().int().optional().nullable(), + maxGrade: z.number().int().optional().nullable(), + minAscents: z.number().int().optional().nullable(), + minRating: z.number().optional().nullable(), + sortBy: z.string().optional().nullable(), + sortOrder: z.string().optional().nullable(), + name: z.string().max(200).optional().nullable(), + settername: z.array(z.string()).optional().nullable(), + onlyClassics: z.boolean().optional().nullable(), + onlyTallClimbs: z.boolean().optional().nullable(), + holdsFilter: z.record(z.string()).optional().nullable(), + hideAttempted: z.boolean().optional().nullable(), + hideCompleted: z.boolean().optional().nullable(), + showOnlyAttempted: z.boolean().optional().nullable(), + showOnlyCompleted: z.boolean().optional().nullable(), +}); + /** * Search playlists input validation schema */ diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 39a6844f..21220a9c 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -426,6 +426,8 @@ export const typeDefs = /* GraphQL */ ` displayName: String "URL to user's avatar image" avatarUrl: String + "URL to user's Instagram profile" + instagramUrl: String } """ @@ -436,6 +438,8 @@ export const typeDefs = /* GraphQL */ ` displayName: String "New avatar URL" avatarUrl: String + "New Instagram profile URL" + instagramUrl: String } """ @@ -468,6 +472,12 @@ export const typeDefs = /* GraphQL */ ` syncedAt: String "Whether a valid token is stored" hasToken: Boolean! + "Sync status: active, error, expired, syncing" + syncStatus: String + "Error message if sync failed" + syncError: String + "When credentials were created (ISO 8601)" + createdAt: String } """ @@ -1991,6 +2001,8 @@ export const typeDefs = /* GraphQL */ ` displayName: String "Avatar URL" avatarUrl: String + "URL to user's Instagram profile" + instagramUrl: String "Number of followers" followerCount: Int! "Number of users being followed" @@ -2450,6 +2462,186 @@ export const typeDefs = /* GraphQL */ ` synced: Boolean! } + # ============================================ + # Beta Link Types + # ============================================ + + """ + A beta video link for a climb. + """ + type BetaLink { + climbUuid: String! + link: String! + foreignUsername: String + angle: Int + thumbnail: String + isListed: Boolean + createdAt: String + } + + # ============================================ + # Climb Stats Types + # ============================================ + + """ + Climb statistics for a specific angle. + """ + type ClimbStatsForAngle { + angle: Int! + ascensionistCount: Int! + qualityAverage: String + difficultyAverage: Float + displayDifficulty: Float + faUsername: String + faAt: String + difficulty: String + } + + # ============================================ + # Hold Classification Types + # ============================================ + + """ + A user's classification of a hold on a board. + """ + type HoldClassification { + id: ID! + userId: String! + boardType: String! + layoutId: Int! + sizeId: Int! + holdId: Int! + holdType: String + handRating: Int + footRating: Int + pullDirection: Int + createdAt: String + updatedAt: String + } + + input GetHoldClassificationsInput { + boardType: String! + layoutId: Int! + sizeId: Int! + } + + input SaveHoldClassificationInput { + boardType: String! + layoutId: Int! + sizeId: Int! + holdId: Int! + holdType: String + handRating: Int + footRating: Int + pullDirection: Int + } + + # ============================================ + # User Board Mapping Types + # ============================================ + + """ + Mapping between a Boardsesh user and their Aurora board account. + """ + type UserBoardMapping { + id: ID! + userId: String! + boardType: String! + boardUserId: Int! + boardUsername: String + createdAt: String + } + + input SaveUserBoardMappingInput { + boardType: String! + boardUserId: Int! + boardUsername: String + } + + # ============================================ + # Unsynced Counts Types + # ============================================ + + """ + Count of unsynced items for a specific board type. + """ + type BoardUnsyncedCount { + ascents: Int! + climbs: Int! + } + + """ + Unsynced item counts across all board types. + """ + type UnsyncedCounts { + kilter: BoardUnsyncedCount! + tension: BoardUnsyncedCount! + } + + """ + Setter statistics for a board configuration. + """ + type SetterStat { + setterUsername: String! + climbCount: Int! + } + + """ + Input for setter stats query. + """ + input SetterStatsInput { + boardName: String! + layoutId: Int! + sizeId: Int! + setIds: String! + angle: Int! + search: String + } + + """ + Hold heatmap statistics for a single hold. + """ + type HoldHeatmapStat { + holdId: Int! + totalUses: Int! + startingUses: Int! + totalAscents: Int! + handUses: Int! + footUses: Int! + finishUses: Int! + averageDifficulty: Float + userAscents: Int + userAttempts: Int + } + + """ + Input for hold heatmap query. + Reuses the same filter parameters as climb search. + """ + input HoldHeatmapInput { + boardName: String! + layoutId: Int! + sizeId: Int! + setIds: String! + angle: Int! + gradeAccuracy: String + minGrade: Int + maxGrade: Int + minAscents: Int + minRating: Float + sortBy: String + sortOrder: String + name: String + settername: [String!] + onlyClassics: Boolean + onlyTallClimbs: Boolean + holdsFilter: JSON + hideAttempted: Boolean + hideCompleted: Boolean + showOnlyAttempted: Boolean + showOnlyCompleted: Boolean + } + """ Root query type for all read operations. """ @@ -2660,6 +2852,50 @@ export const typeDefs = /* GraphQL */ ` # Get current user's registered controllers myControllers: [ControllerInfo!]! + # ============================================ + # Data Query Endpoints (migrated from Next.js) + # ============================================ + + """ + Get beta video links for a climb. + """ + betaLinks(boardName: String!, climbUuid: String!): [BetaLink!]! + + """ + Get climb statistics across all angles. + """ + climbStatsForAllAngles(boardName: String!, climbUuid: String!): [ClimbStatsForAngle!]! + + """ + Get hold classifications for the current user and board configuration. + Requires authentication. + """ + holdClassifications(input: GetHoldClassificationsInput!): [HoldClassification!]! + + """ + Get user board mappings for the current user. + Requires authentication. + """ + userBoardMappings: [UserBoardMapping!]! + + """ + Get count of unsynced items for the current user's Aurora accounts. + Requires authentication. + """ + unsyncedCounts: UnsyncedCounts! + + """ + Get setter statistics for a board configuration. + Returns top setters by climb count. + """ + setterStats(input: SetterStatsInput!): [SetterStat!]! + + """ + Get hold heatmap data for a board configuration. + Returns hold usage statistics with optional user-specific data. + """ + holdHeatmap(input: HoldHeatmapInput!): [HoldHeatmapStat!]! + # ============================================ # Social / Follow Queries # ============================================ @@ -3044,6 +3280,23 @@ export const typeDefs = /* GraphQL */ ` registerController(input: RegisterControllerInput!): ControllerRegistration! # Delete a registered controller - requires auth deleteController(controllerId: ID!): Boolean! + + # ============================================ + # Data Mutation Endpoints (migrated from Next.js) + # ============================================ + + """ + Save or update a hold classification. + Requires authentication. + """ + saveHoldClassification(input: SaveHoldClassificationInput!): HoldClassification! + + """ + Save a user board mapping. + Requires authentication. + """ + saveUserBoardMapping(input: SaveUserBoardMappingInput!): Boolean! + # ============================================ # Social / Follow Mutations (require auth) # ============================================ diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 4e68ed93..2d2ed473 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -168,11 +168,13 @@ export type UserProfile = { email: string; displayName?: string; avatarUrl?: string; + instagramUrl?: string; }; export type UpdateProfileInput = { displayName?: string; avatarUrl?: string; + instagramUrl?: string; }; export type AuroraCredential = { @@ -189,6 +191,9 @@ export type AuroraCredentialStatus = { userId?: number; syncedAt?: string; hasToken: boolean; + syncStatus?: string; + syncError?: string; + createdAt?: string; }; export type SaveAuroraCredentialInput = { @@ -555,6 +560,155 @@ export type SearchPlaylistsResult = { hasMore: boolean; }; +// ============================================ +// Beta Link Types +// ============================================ + +export type BetaLink = { + climbUuid: string; + link: string; + foreignUsername?: string | null; + angle?: number | null; + thumbnail?: string | null; + isListed?: boolean | null; + createdAt?: string | null; +}; + +// ============================================ +// Climb Stats Types +// ============================================ + +export type ClimbStatsForAngle = { + angle: number; + ascensionistCount: number; + qualityAverage?: string | null; + difficultyAverage?: number | null; + displayDifficulty?: number | null; + faUsername?: string | null; + faAt?: string | null; + difficulty?: string | null; +}; + +// ============================================ +// Hold Classification Types +// ============================================ + +export type HoldClassification = { + id: string; + userId: string; + boardType: string; + layoutId: number; + sizeId: number; + holdId: number; + holdType?: string | null; + handRating?: number | null; + footRating?: number | null; + pullDirection?: number | null; + createdAt?: string | null; + updatedAt?: string | null; +}; + +export type GetHoldClassificationsInput = { + boardType: string; + layoutId: number; + sizeId: number; +}; + +export type SaveHoldClassificationInput = { + boardType: string; + layoutId: number; + sizeId: number; + holdId: number; + holdType?: string | null; + handRating?: number | null; + footRating?: number | null; + pullDirection?: number | null; +}; + +// ============================================ +// User Board Mapping Types +// ============================================ + +export type UserBoardMapping = { + id: string; + userId: string; + boardType: string; + boardUserId: number; + boardUsername?: string | null; + createdAt?: string | null; +}; + +export type SaveUserBoardMappingInput = { + boardType: string; + boardUserId: number; + boardUsername?: string | null; +}; + +// ============================================ +// Unsynced Counts Types +// ============================================ + +export type BoardUnsyncedCount = { + ascents: number; + climbs: number; +}; + +export type UnsyncedCounts = { + kilter: BoardUnsyncedCount; + tension: BoardUnsyncedCount; +}; + +export type SetterStat = { + setterUsername: string; + climbCount: number; +}; + +export type SetterStatsInput = { + boardName: string; + layoutId: number; + sizeId: number; + setIds: string; + angle: number; + search?: string | null; +}; + +export type HoldHeatmapStat = { + holdId: number; + totalUses: number; + startingUses: number; + totalAscents: number; + handUses: number; + footUses: number; + finishUses: number; + averageDifficulty: number | null; + userAscents?: number | null; + userAttempts?: number | null; +}; + +export type HoldHeatmapInput = { + boardName: string; + layoutId: number; + sizeId: number; + setIds: string; + angle: number; + gradeAccuracy?: string | null; + minGrade?: number | null; + maxGrade?: number | null; + minAscents?: number | null; + minRating?: number | null; + sortBy?: string | null; + sortOrder?: string | null; + name?: string | null; + settername?: string[] | null; + onlyClassics?: boolean | null; + onlyTallClimbs?: boolean | null; + holdsFilter?: Record | null; + hideAttempted?: boolean | null; + hideCompleted?: boolean | null; + showOnlyAttempted?: boolean | null; + showOnlyCompleted?: boolean | null; +}; + // ============================================ // Social / Follow Types // ============================================ @@ -563,6 +717,7 @@ export type PublicUserProfile = { id: string; displayName?: string; avatarUrl?: string; + instagramUrl?: string; followerCount: number; followingCount: number; isFollowedByMe: boolean; diff --git a/packages/web/app/api/internal/aurora-credentials/unsynced/route.ts b/packages/web/app/api/internal/aurora-credentials/unsynced/route.ts deleted file mode 100644 index aa505091..00000000 --- a/packages/web/app/api/internal/aurora-credentials/unsynced/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getServerSession } from "next-auth/next"; -import { NextResponse } from "next/server"; -import { getDb } from "@/app/lib/db/db"; -import { auroraCredentials, boardseshTicks, boardClimbs } from "@/app/lib/db/schema"; -import { eq, and, isNull, count } from "drizzle-orm"; -import { authOptions } from "@/app/lib/auth/auth-options"; - -export interface UnsyncedCounts { - kilter: { - ascents: number; - climbs: number; - }; - tension: { - ascents: number; - climbs: number; - }; -} - -/** - * GET - Get count of unsynced items for the logged-in user's Aurora accounts - */ -export async function GET() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const db = getDb(); - - // Get user's Aurora account user IDs from credentials - const credentials = await db - .select({ - boardType: auroraCredentials.boardType, - auroraUserId: auroraCredentials.auroraUserId, - }) - .from(auroraCredentials) - .where(eq(auroraCredentials.userId, session.user.id)); - - const counts: UnsyncedCounts = { - kilter: { ascents: 0, climbs: 0 }, - tension: { ascents: 0, climbs: 0 }, - }; - - for (const cred of credentials) { - if (!cred.auroraUserId) continue; - - const boardType = cred.boardType as 'kilter' | 'tension'; - - // Count unsynced ticks (ascents/bids) for this user from boardsesh_ticks - // Note: boardsesh_ticks uses NextAuth userId, not Aurora user_id - // Unsynced ticks are those without an auroraId - const [ascentResult] = await db - .select({ count: count() }) - .from(boardseshTicks) - .where( - and( - eq(boardseshTicks.userId, session.user.id), - eq(boardseshTicks.boardType, boardType), - isNull(boardseshTicks.auroraId), - ), - ); - - // Count unsynced climbs for this user - const [climbResult] = await db - .select({ count: count() }) - .from(boardClimbs) - .where( - and( - eq(boardClimbs.boardType, boardType), - eq(boardClimbs.setterId, cred.auroraUserId), - eq(boardClimbs.synced, false), - ), - ); - - if (boardType === 'kilter') { - counts.kilter.ascents = ascentResult?.count ?? 0; - counts.kilter.climbs = climbResult?.count ?? 0; - } else if (boardType === 'tension') { - counts.tension.ascents = ascentResult?.count ?? 0; - counts.tension.climbs = climbResult?.count ?? 0; - } - } - - return NextResponse.json({ counts }); - } catch (error) { - console.error("Failed to get unsynced counts:", error); - return NextResponse.json({ error: "Failed to get unsynced counts" }, { status: 500 }); - } -} diff --git a/packages/web/app/api/internal/controllers/route.ts b/packages/web/app/api/internal/controllers/route.ts deleted file mode 100644 index 65093275..00000000 --- a/packages/web/app/api/internal/controllers/route.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { getServerSession } from "next-auth/next"; -import { NextRequest, NextResponse } from "next/server"; -import { getDb } from "@/app/lib/db/db"; -import { esp32Controllers } from "@boardsesh/db/schema/app"; -import { eq, and } from "drizzle-orm"; -import { z } from "zod"; -import { authOptions } from "@/app/lib/auth/auth-options"; -import { randomBytes } from "crypto"; - -const registerControllerSchema = z.object({ - name: z.string().max(100).optional(), - boardName: z.enum(["kilter", "tension"]), - layoutId: z.number().int().positive(), - sizeId: z.number().int().positive(), - setIds: z.string().min(1), -}); - -const deleteControllerSchema = z.object({ - controllerId: z.string().uuid(), -}); - -export interface ControllerInfo { - id: string; - name: string | null; - boardName: string; - layoutId: number; - sizeId: number; - setIds: string; - isOnline: boolean; - lastSeen: string | null; - createdAt: string; -} - -// Consider controller online if seen within last 60 seconds -const ONLINE_THRESHOLD_MS = 60 * 1000; - -/** - * Generate a secure random API key - */ -function generateApiKey(): string { - return randomBytes(32).toString('hex'); -} - -/** - * GET - Get all ESP32 controllers registered by the logged-in user - */ -export async function GET() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const db = getDb(); - - const controllers = await db - .select() - .from(esp32Controllers) - .where(eq(esp32Controllers.userId, session.user.id)); - - const now = Date.now(); - - const controllerList: ControllerInfo[] = controllers.map((controller) => ({ - id: controller.id, - name: controller.name, - boardName: controller.boardName, - layoutId: controller.layoutId, - sizeId: controller.sizeId, - setIds: controller.setIds, - isOnline: controller.lastSeenAt - ? now - controller.lastSeenAt.getTime() < ONLINE_THRESHOLD_MS - : false, - lastSeen: controller.lastSeenAt?.toISOString() ?? null, - createdAt: controller.createdAt.toISOString(), - })); - - return NextResponse.json({ controllers: controllerList }); - } catch (error) { - console.error("Failed to get controllers:", error); - return NextResponse.json({ error: "Failed to get controllers" }, { status: 500 }); - } -} - -/** - * POST - Register a new ESP32 controller - * Returns the API key ONCE - it cannot be retrieved again - */ -export async function POST(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - - const validationResult = registerControllerSchema.safeParse(body); - if (!validationResult.success) { - return NextResponse.json( - { error: validationResult.error.issues[0].message }, - { status: 400 } - ); - } - - const { name, boardName, layoutId, sizeId, setIds } = validationResult.data; - const apiKey = generateApiKey(); - - const db = getDb(); - - const [controller] = await db - .insert(esp32Controllers) - .values({ - userId: session.user.id, - apiKey, - name: name ?? null, - boardName, - layoutId, - sizeId, - setIds, - }) - .returning(); - - return NextResponse.json({ - success: true, - controllerId: controller.id, - apiKey, // Only returned on creation - save it now! - controller: { - id: controller.id, - name: controller.name, - boardName: controller.boardName, - layoutId: controller.layoutId, - sizeId: controller.sizeId, - setIds: controller.setIds, - isOnline: false, - lastSeen: null, - createdAt: controller.createdAt.toISOString(), - }, - }); - } catch (error) { - console.error("Failed to register controller:", error); - return NextResponse.json({ error: "Failed to register controller" }, { status: 500 }); - } -} - -/** - * DELETE - Remove an ESP32 controller - */ -export async function DELETE(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - - const validationResult = deleteControllerSchema.safeParse(body); - if (!validationResult.success) { - return NextResponse.json( - { error: validationResult.error.issues[0].message }, - { status: 400 } - ); - } - - const { controllerId } = validationResult.data; - const db = getDb(); - - // Only delete if user owns the controller - await db - .delete(esp32Controllers) - .where( - and( - eq(esp32Controllers.id, controllerId), - eq(esp32Controllers.userId, session.user.id) - ) - ); - - return NextResponse.json({ success: true }); - } catch (error) { - console.error("Failed to delete controller:", error); - return NextResponse.json({ error: "Failed to delete controller" }, { status: 500 }); - } -} diff --git a/packages/web/app/api/internal/favorites/route.ts b/packages/web/app/api/internal/favorites/route.ts deleted file mode 100644 index e8e846a6..00000000 --- a/packages/web/app/api/internal/favorites/route.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { getServerSession } from "next-auth/next"; -import { NextRequest, NextResponse } from "next/server"; -import { getDb } from "@/app/lib/db/db"; -import * as schema from "@/app/lib/db/schema"; -import { eq, and } from "drizzle-orm"; -import { z } from "zod"; -import { authOptions } from "@/app/lib/auth/auth-options"; - -const favoriteSchema = z.object({ - boardName: z.enum(["kilter", "tension", "moonboard"]), - climbUuid: z.string().min(1), - angle: z.number().int(), -}); - -const checkFavoriteSchema = z.object({ - boardName: z.enum(["kilter", "tension", "moonboard"]), - climbUuids: z.array(z.string().min(1)), - angle: z.number().int(), -}); - -// POST: Toggle favorite (add or remove) -export async function POST(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const validationResult = favoriteSchema.safeParse(body); - - if (!validationResult.success) { - return NextResponse.json( - { error: validationResult.error.issues[0].message }, - { status: 400 } - ); - } - - const { boardName, climbUuid, angle } = validationResult.data; - const db = getDb(); - - // Check if favorite already exists - const existing = await db - .select() - .from(schema.userFavorites) - .where( - and( - eq(schema.userFavorites.userId, session.user.id), - eq(schema.userFavorites.boardName, boardName), - eq(schema.userFavorites.climbUuid, climbUuid), - eq(schema.userFavorites.angle, angle) - ) - ) - .limit(1); - - if (existing.length > 0) { - // Remove favorite - await db - .delete(schema.userFavorites) - .where( - and( - eq(schema.userFavorites.userId, session.user.id), - eq(schema.userFavorites.boardName, boardName), - eq(schema.userFavorites.climbUuid, climbUuid), - eq(schema.userFavorites.angle, angle) - ) - ); - return NextResponse.json({ favorited: false }); - } else { - // Add favorite - await db.insert(schema.userFavorites).values({ - userId: session.user.id, - boardName, - climbUuid, - angle, - }); - return NextResponse.json({ favorited: true }); - } - } catch (error) { - console.error("Failed to toggle favorite:", error); - return NextResponse.json({ error: "Failed to toggle favorite" }, { status: 500 }); - } -} - -// GET: Check if climbs are favorited (batch check) -export async function GET(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - // Return empty favorites for non-authenticated users - return NextResponse.json({ favorites: [] }); - } - - const { searchParams } = new URL(request.url); - const boardName = searchParams.get("boardName"); - const climbUuidsParam = searchParams.get("climbUuids"); - const angleParam = searchParams.get("angle"); - - if (!boardName || !climbUuidsParam || !angleParam) { - return NextResponse.json( - { error: "Missing required parameters" }, - { status: 400 } - ); - } - - const climbUuids = climbUuidsParam.split(","); - const angle = parseInt(angleParam, 10); - - if (isNaN(angle)) { - return NextResponse.json( - { error: "Invalid angle parameter" }, - { status: 400 } - ); - } - - const validationResult = checkFavoriteSchema.safeParse({ - boardName, - climbUuids, - angle, - }); - - if (!validationResult.success) { - return NextResponse.json( - { error: validationResult.error.issues[0].message }, - { status: 400 } - ); - } - - const db = getDb(); - - // Get all favorites for the user matching the given climbs - const favorites = await db - .select({ climbUuid: schema.userFavorites.climbUuid }) - .from(schema.userFavorites) - .where( - and( - eq(schema.userFavorites.userId, session.user.id), - eq(schema.userFavorites.boardName, boardName), - eq(schema.userFavorites.angle, angle) - ) - ); - - // Filter to only the requested climb UUIDs - const favoritedUuids = favorites - .map((f) => f.climbUuid) - .filter((uuid) => climbUuids.includes(uuid)); - - return NextResponse.json({ favorites: favoritedUuids }); - } catch (error) { - console.error("Failed to check favorites:", error); - return NextResponse.json({ error: "Failed to check favorites" }, { status: 500 }); - } -} diff --git a/packages/web/app/api/internal/hold-classifications/__tests__/validation.test.ts b/packages/web/app/api/internal/hold-classifications/__tests__/validation.test.ts deleted file mode 100644 index cc38e459..00000000 --- a/packages/web/app/api/internal/hold-classifications/__tests__/validation.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - VALID_BOARD_TYPES, - VALID_HOLD_TYPES, - parseIntSafe, - isValidBoardType, - isValidHoldType, - isValidRating, - isValidPullDirection, -} from '../validation'; - -describe('hold-classifications validation', () => { - describe('parseIntSafe', () => { - it('should parse valid integer strings', () => { - expect(parseIntSafe('123')).toBe(123); - expect(parseIntSafe('0')).toBe(0); - expect(parseIntSafe('-5')).toBe(-5); - }); - - it('should return null for invalid inputs', () => { - expect(parseIntSafe(null)).toBe(null); - expect(parseIntSafe('abc')).toBe(null); - expect(parseIntSafe('')).toBe(null); - expect(parseIntSafe('12.5')).toBe(12); // parseInt behavior - }); - }); - - describe('isValidBoardType', () => { - it('should accept valid board types', () => { - expect(isValidBoardType('kilter')).toBe(true); - expect(isValidBoardType('tension')).toBe(true); - expect(isValidBoardType('moonboard')).toBe(true); - }); - - it('should reject invalid board types', () => { - expect(isValidBoardType('invalid')).toBe(false); - expect(isValidBoardType('')).toBe(false); - expect(isValidBoardType(null)).toBe(false); - expect(isValidBoardType(undefined)).toBe(false); - expect(isValidBoardType(123)).toBe(false); - expect(isValidBoardType({})).toBe(false); - }); - - it('should have correct board types in constant', () => { - expect(VALID_BOARD_TYPES).toContain('kilter'); - expect(VALID_BOARD_TYPES).toContain('tension'); - expect(VALID_BOARD_TYPES).toContain('moonboard'); - expect(VALID_BOARD_TYPES.length).toBe(3); - }); - }); - - describe('isValidHoldType', () => { - it('should accept valid hold types', () => { - expect(isValidHoldType('jug')).toBe(true); - expect(isValidHoldType('sloper')).toBe(true); - expect(isValidHoldType('pinch')).toBe(true); - expect(isValidHoldType('crimp')).toBe(true); - expect(isValidHoldType('pocket')).toBe(true); - }); - - it('should reject removed hold types', () => { - expect(isValidHoldType('edge')).toBe(false); - expect(isValidHoldType('sidepull')).toBe(false); - expect(isValidHoldType('undercling')).toBe(false); - }); - - it('should reject invalid hold types', () => { - expect(isValidHoldType('invalid')).toBe(false); - expect(isValidHoldType('')).toBe(false); - expect(isValidHoldType(null)).toBe(false); - expect(isValidHoldType(undefined)).toBe(false); - expect(isValidHoldType(123)).toBe(false); - }); - - it('should have correct hold types in constant', () => { - expect(VALID_HOLD_TYPES).toContain('jug'); - expect(VALID_HOLD_TYPES).toContain('sloper'); - expect(VALID_HOLD_TYPES).toContain('pinch'); - expect(VALID_HOLD_TYPES).toContain('crimp'); - expect(VALID_HOLD_TYPES).toContain('pocket'); - expect(VALID_HOLD_TYPES).not.toContain('edge'); - expect(VALID_HOLD_TYPES).not.toContain('sidepull'); - expect(VALID_HOLD_TYPES).not.toContain('undercling'); - expect(VALID_HOLD_TYPES.length).toBe(5); - }); - }); - - describe('isValidRating', () => { - it('should accept ratings 1-5', () => { - expect(isValidRating(1)).toBe(true); - expect(isValidRating(2)).toBe(true); - expect(isValidRating(3)).toBe(true); - expect(isValidRating(4)).toBe(true); - expect(isValidRating(5)).toBe(true); - }); - - it('should reject out of range ratings', () => { - expect(isValidRating(0)).toBe(false); - expect(isValidRating(6)).toBe(false); - expect(isValidRating(-1)).toBe(false); - expect(isValidRating(100)).toBe(false); - }); - - it('should reject non-integer ratings', () => { - expect(isValidRating(1.5)).toBe(false); - expect(isValidRating(2.7)).toBe(false); - }); - - it('should reject non-number values', () => { - expect(isValidRating('3')).toBe(false); - expect(isValidRating(null)).toBe(false); - expect(isValidRating(undefined)).toBe(false); - expect(isValidRating({})).toBe(false); - }); - }); - - describe('isValidPullDirection', () => { - it('should accept valid angles 0-360', () => { - expect(isValidPullDirection(0)).toBe(true); - expect(isValidPullDirection(90)).toBe(true); - expect(isValidPullDirection(180)).toBe(true); - expect(isValidPullDirection(270)).toBe(true); - expect(isValidPullDirection(360)).toBe(true); - expect(isValidPullDirection(45)).toBe(true); - }); - - it('should reject out of range angles', () => { - expect(isValidPullDirection(-1)).toBe(false); - expect(isValidPullDirection(361)).toBe(false); - expect(isValidPullDirection(-90)).toBe(false); - expect(isValidPullDirection(720)).toBe(false); - }); - - it('should reject non-integer angles', () => { - expect(isValidPullDirection(45.5)).toBe(false); - expect(isValidPullDirection(90.1)).toBe(false); - }); - - it('should reject non-number values', () => { - expect(isValidPullDirection('90')).toBe(false); - expect(isValidPullDirection(null)).toBe(false); - expect(isValidPullDirection(undefined)).toBe(false); - expect(isValidPullDirection({})).toBe(false); - }); - }); -}); diff --git a/packages/web/app/api/internal/hold-classifications/route.ts b/packages/web/app/api/internal/hold-classifications/route.ts deleted file mode 100644 index 8164896d..00000000 --- a/packages/web/app/api/internal/hold-classifications/route.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { getServerSession } from 'next-auth/next'; -import { NextRequest, NextResponse } from 'next/server'; -import { getDb } from '@/app/lib/db/db'; -import * as schema from '@/app/lib/db/schema'; -import { eq, and } from 'drizzle-orm'; -import { authOptions } from '@/app/lib/auth/auth-options'; -import { - VALID_BOARD_TYPES, - VALID_HOLD_TYPES, - parseIntSafe, - isValidBoardType, - isValidHoldType, - isValidRating, - isValidPullDirection, -} from './validation'; - -/** - * GET /api/internal/hold-classifications - * Fetches all hold classifications for the current user and board configuration - */ -export async function GET(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const searchParams = request.nextUrl.searchParams; - const boardType = searchParams.get('boardType'); - const layoutIdParam = searchParams.get('layoutId'); - const sizeIdParam = searchParams.get('sizeId'); - - if (!boardType || !layoutIdParam || !sizeIdParam) { - return NextResponse.json( - { error: 'Missing required parameters: boardType, layoutId, sizeId' }, - { status: 400 } - ); - } - - if (!isValidBoardType(boardType)) { - return NextResponse.json( - { error: `boardType must be one of: ${VALID_BOARD_TYPES.join(', ')}` }, - { status: 400 } - ); - } - - const layoutId = parseIntSafe(layoutIdParam); - const sizeId = parseIntSafe(sizeIdParam); - - if (layoutId === null || sizeId === null) { - return NextResponse.json( - { error: 'layoutId and sizeId must be valid integers' }, - { status: 400 } - ); - } - - const db = getDb(); - - const classifications = await db - .select() - .from(schema.userHoldClassifications) - .where( - and( - eq(schema.userHoldClassifications.userId, session.user.id), - eq(schema.userHoldClassifications.boardType, boardType), - eq(schema.userHoldClassifications.layoutId, layoutId), - eq(schema.userHoldClassifications.sizeId, sizeId) - ) - ); - - return NextResponse.json({ - classifications: classifications.map((c) => ({ - id: c.id.toString(), - userId: c.userId, - boardType: c.boardType, - layoutId: c.layoutId, - sizeId: c.sizeId, - holdId: c.holdId, - holdType: c.holdType, - handRating: c.handRating, - footRating: c.footRating, - pullDirection: c.pullDirection, - createdAt: c.createdAt, - updatedAt: c.updatedAt, - })), - }); - } catch (error) { - console.error('Failed to get hold classifications:', error); - return NextResponse.json( - { error: 'Failed to get hold classifications' }, - { status: 500 } - ); - } -} - -/** - * POST /api/internal/hold-classifications - * Creates or updates a hold classification for the current user - */ -export async function POST(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await request.json(); - const { boardType, layoutId, sizeId, holdId, holdType, handRating, footRating, pullDirection } = body; - - // Validate required fields - if (!isValidBoardType(boardType)) { - return NextResponse.json( - { error: `boardType must be one of: ${VALID_BOARD_TYPES.join(', ')}` }, - { status: 400 } - ); - } - - if (typeof layoutId !== 'number' || !Number.isInteger(layoutId)) { - return NextResponse.json( - { error: 'layoutId must be an integer' }, - { status: 400 } - ); - } - - if (typeof sizeId !== 'number' || !Number.isInteger(sizeId)) { - return NextResponse.json( - { error: 'sizeId must be an integer' }, - { status: 400 } - ); - } - - if (typeof holdId !== 'number' || !Number.isInteger(holdId)) { - return NextResponse.json( - { error: 'holdId must be an integer' }, - { status: 400 } - ); - } - - // Validate optional fields - if (holdType !== null && holdType !== undefined && !isValidHoldType(holdType)) { - return NextResponse.json( - { error: `holdType must be one of: ${VALID_HOLD_TYPES.join(', ')}` }, - { status: 400 } - ); - } - - if (handRating !== null && handRating !== undefined && !isValidRating(handRating)) { - return NextResponse.json( - { error: 'handRating must be an integer between 1 and 5' }, - { status: 400 } - ); - } - - if (footRating !== null && footRating !== undefined && !isValidRating(footRating)) { - return NextResponse.json( - { error: 'footRating must be an integer between 1 and 5' }, - { status: 400 } - ); - } - - if (pullDirection !== null && pullDirection !== undefined && !isValidPullDirection(pullDirection)) { - return NextResponse.json( - { error: 'pullDirection must be an integer between 0 and 360' }, - { status: 400 } - ); - } - - const db = getDb(); - - // Check if a classification already exists - const existing = await db - .select() - .from(schema.userHoldClassifications) - .where( - and( - eq(schema.userHoldClassifications.userId, session.user.id), - eq(schema.userHoldClassifications.boardType, boardType), - eq(schema.userHoldClassifications.layoutId, layoutId), - eq(schema.userHoldClassifications.sizeId, sizeId), - eq(schema.userHoldClassifications.holdId, holdId) - ) - ) - .limit(1); - - const now = new Date().toISOString(); - const validatedHoldType = holdType ?? null; - const validatedHandRating = handRating ?? null; - const validatedFootRating = footRating ?? null; - const validatedPullDirection = pullDirection ?? null; - - if (existing.length > 0) { - // Update existing classification - await db - .update(schema.userHoldClassifications) - .set({ - holdType: validatedHoldType, - handRating: validatedHandRating, - footRating: validatedFootRating, - pullDirection: validatedPullDirection, - updatedAt: now, - }) - .where(eq(schema.userHoldClassifications.id, existing[0].id)); - - return NextResponse.json({ - success: true, - classification: { - id: existing[0].id.toString(), - userId: session.user.id, - boardType, - layoutId, - sizeId, - holdId, - holdType: validatedHoldType, - handRating: validatedHandRating, - footRating: validatedFootRating, - pullDirection: validatedPullDirection, - createdAt: existing[0].createdAt, - updatedAt: now, - }, - }); - } else { - // Create new classification - const result = await db - .insert(schema.userHoldClassifications) - .values({ - userId: session.user.id, - boardType, - layoutId, - sizeId, - holdId, - holdType: validatedHoldType, - handRating: validatedHandRating, - footRating: validatedFootRating, - pullDirection: validatedPullDirection, - createdAt: now, - updatedAt: now, - }) - .returning(); - - return NextResponse.json({ - success: true, - classification: { - id: result[0].id.toString(), - userId: session.user.id, - boardType, - layoutId, - sizeId, - holdId, - holdType: validatedHoldType, - handRating: validatedHandRating, - footRating: validatedFootRating, - pullDirection: validatedPullDirection, - createdAt: now, - updatedAt: now, - }, - }); - } - } catch (error) { - console.error('Failed to save hold classification:', error); - return NextResponse.json( - { error: 'Failed to save hold classification' }, - { status: 500 } - ); - } -} diff --git a/packages/web/app/api/internal/hold-classifications/validation.ts b/packages/web/app/api/internal/hold-classifications/validation.ts deleted file mode 100644 index d5cd3e07..00000000 --- a/packages/web/app/api/internal/hold-classifications/validation.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { SUPPORTED_BOARDS } from '@/app/lib/board-data'; -import type { BoardName } from '@/app/lib/types'; - -// Valid board types - use the centralized SUPPORTED_BOARDS constant -export const VALID_BOARD_TYPES = SUPPORTED_BOARDS; -export type ValidBoardType = BoardName; - -// Valid hold types matching the database enum -export const VALID_HOLD_TYPES = ['jug', 'sloper', 'pinch', 'crimp', 'pocket'] as const; -export type ValidHoldType = (typeof VALID_HOLD_TYPES)[number]; - -/** - * Validates and parses an integer from a string - * Returns null if invalid - */ -export function parseIntSafe(value: string | null): number | null { - if (value === null) return null; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? null : parsed; -} - -/** - * Validates board type against known boards - */ -export function isValidBoardType(value: unknown): value is ValidBoardType { - return typeof value === 'string' && (VALID_BOARD_TYPES as readonly string[]).includes(value); -} - -/** - * Validates hold type against allowed enum values - */ -export function isValidHoldType(value: unknown): value is ValidHoldType { - return typeof value === 'string' && VALID_HOLD_TYPES.includes(value as ValidHoldType); -} - -/** - * Validates a rating is in range 1-5 - */ -export function isValidRating(value: unknown): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 5; -} - -/** - * Validates pull direction is in range 0-360 - */ -export function isValidPullDirection(value: unknown): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 360; -} diff --git a/packages/web/app/api/internal/profile/[userId]/route.ts b/packages/web/app/api/internal/profile/[userId]/route.ts deleted file mode 100644 index 299474cc..00000000 --- a/packages/web/app/api/internal/profile/[userId]/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { getServerSession } from "next-auth/next"; -import { NextRequest, NextResponse } from "next/server"; -import { getDb } from "@/app/lib/db/db"; -import * as schema from "@/app/lib/db/schema"; -import { eq, and, count } from "drizzle-orm"; -import { authOptions } from "@/app/lib/auth/auth-options"; -import { getUserBoardMappings } from "@/app/lib/auth/user-board-mappings"; - -type RouteParams = { - params: Promise<{ userId: string }>; -}; - -export async function GET(request: NextRequest, { params }: RouteParams) { - try { - const { userId } = await params; - const session = await getServerSession(authOptions); - const isOwnProfile = session?.user?.id === userId; - - const db = getDb(); - - // Get user profile - const profiles = await db - .select() - .from(schema.userProfiles) - .where(eq(schema.userProfiles.userId, userId)) - .limit(1); - - // Get base user data - const users = await db - .select() - .from(schema.users) - .where(eq(schema.users.id, userId)) - .limit(1); - - if (users.length === 0) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - const user = users[0]; - const profile = profiles.length > 0 ? profiles[0] : null; - - // Get board mappings for this user - const mappings = await getUserBoardMappings(userId); - - // Transform mappings to credential format - const credentials = mappings.map((m) => ({ - boardType: m.boardType, - auroraUsername: m.boardUsername || '', - auroraUserId: m.boardUserId, - })); - - // Get follower/following counts - const [followerCountResult] = await db - .select({ count: count() }) - .from(schema.userFollows) - .where(eq(schema.userFollows.followingId, userId)); - - const [followingCountResult] = await db - .select({ count: count() }) - .from(schema.userFollows) - .where(eq(schema.userFollows.followerId, userId)); - - const followerCount = Number(followerCountResult?.count || 0); - const followingCount = Number(followingCountResult?.count || 0); - - // Check if current user follows this profile - let isFollowedByMe = false; - if (session?.user?.id && session.user.id !== userId) { - const [followCheck] = await db - .select({ count: count() }) - .from(schema.userFollows) - .where( - and( - eq(schema.userFollows.followerId, session.user.id), - eq(schema.userFollows.followingId, userId) - ) - ); - isFollowedByMe = Number(followCheck?.count || 0) > 0; - } - - return NextResponse.json({ - id: user.id, - // Only include email if viewing own profile - email: isOwnProfile ? user.email : undefined, - name: user.name, - image: user.image, - profile: profile - ? { - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - instagramUrl: profile.instagramUrl, - } - : null, - credentials, - isOwnProfile, - followerCount, - followingCount, - isFollowedByMe, - }); - } catch (error) { - console.error("Failed to get profile:", error); - return NextResponse.json({ error: "Failed to get profile" }, { status: 500 }); - } -} diff --git a/packages/web/app/api/internal/profile/route.ts b/packages/web/app/api/internal/profile/route.ts deleted file mode 100644 index a893bee4..00000000 --- a/packages/web/app/api/internal/profile/route.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { getServerSession } from "next-auth/next"; -import { NextRequest, NextResponse } from "next/server"; -import { getDb } from "@/app/lib/db/db"; -import * as schema from "@/app/lib/db/schema"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { authOptions } from "@/app/lib/auth/auth-options"; - -const updateProfileSchema = z.object({ - displayName: z.string().max(100, "Display name must be less than 100 characters").optional().nullable(), - avatarUrl: z.string().url("Invalid avatar URL").optional().nullable(), - instagramUrl: z.string().url("Invalid Instagram URL").optional().nullable(), -}); - -export async function GET() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const db = getDb(); - - // Get user profile - const profiles = await db - .select() - .from(schema.userProfiles) - .where(eq(schema.userProfiles.userId, session.user.id)) - .limit(1); - - // Get base user data - const users = await db - .select() - .from(schema.users) - .where(eq(schema.users.id, session.user.id)) - .limit(1); - - if (users.length === 0) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - const user = users[0]; - const profile = profiles.length > 0 ? profiles[0] : null; - - return NextResponse.json({ - id: user.id, - email: user.email, - name: user.name, - image: user.image, - profile: profile - ? { - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - instagramUrl: profile.instagramUrl, - } - : null, - }); - } catch (error) { - console.error("Failed to get profile:", error); - return NextResponse.json({ error: "Failed to get profile" }, { status: 500 }); - } -} - -export async function PUT(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - - // Validate input - const validationResult = updateProfileSchema.safeParse(body); - if (!validationResult.success) { - return NextResponse.json( - { error: validationResult.error.issues[0].message }, - { status: 400 } - ); - } - - const { displayName, avatarUrl, instagramUrl } = validationResult.data; - const db = getDb(); - - // Check if profile exists - const existingProfile = await db - .select() - .from(schema.userProfiles) - .where(eq(schema.userProfiles.userId, session.user.id)) - .limit(1); - - const now = new Date(); - - if (existingProfile.length > 0) { - // Update existing profile - await db - .update(schema.userProfiles) - .set({ - displayName: displayName ?? null, - avatarUrl: avatarUrl ?? null, - instagramUrl: instagramUrl ?? null, - updatedAt: now, - }) - .where(eq(schema.userProfiles.userId, session.user.id)); - } else { - // Create new profile - await db.insert(schema.userProfiles).values({ - userId: session.user.id, - displayName: displayName ?? null, - avatarUrl: avatarUrl ?? null, - instagramUrl: instagramUrl ?? null, - }); - } - - // Also update the user's name if displayName is provided - if (displayName !== undefined) { - await db - .update(schema.users) - .set({ - name: displayName || null, - updatedAt: now, - }) - .where(eq(schema.users.id, session.user.id)); - } - - return NextResponse.json({ success: true }); - } catch (error) { - console.error("Failed to update profile:", error); - return NextResponse.json({ error: "Failed to update profile" }, { status: 500 }); - } -} diff --git a/packages/web/app/api/internal/shared-sync/[board_name]/route.ts b/packages/web/app/api/internal/shared-sync/[board_name]/route.ts deleted file mode 100644 index fb9cd845..00000000 --- a/packages/web/app/api/internal/shared-sync/[board_name]/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -// app/api/cron/sync-shared-data/route.ts -import { NextResponse } from 'next/server'; -import { syncSharedData as syncSharedDataFunction } from '@/lib/data-sync/aurora/shared-sync'; -import { BoardName as AuroraBoardName } from '@/app/lib/api-wrappers/aurora-rest-client/types'; -import { AURORA_BOARD_NAMES } from '@/app/lib/board-constants'; - -export const dynamic = 'force-dynamic'; -export const maxDuration = 300; -// This is a simple way to secure the endpoint, should be replaced with a better solution -const CRON_SECRET = process.env.CRON_SECRET; - -type SharedSyncRouteParams = { - board_name: string; -}; - -const internalSyncSharedData = async ( - board_name: AuroraBoardName, - token: string, - previousResults: { results: Record; complete: boolean } = { - results: {}, - complete: false, - }, - recursionCount = 0, -) => { - console.log(`Recursion count: ${recursionCount}`); - if (recursionCount >= 100) { - console.warn('Maximum recursion depth reached for shared sync'); - return { _complete: true, _maxRecursionReached: true, ...previousResults }; - } - - const currentResult = await syncSharedDataFunction(board_name, token); - - // If this is the first run, just return the current result - - // Deep merge the results, adding up synced counts - const mergedResults: { results: Record; complete: boolean } = { - results: {}, - complete: false, - }; - const categories = new Set([...Object.keys(previousResults.results), ...Object.keys(currentResult.results)]); - - for (const category of categories) { - if (category === 'complete') { - mergedResults.complete = currentResult.complete; - continue; - } - - const prev = previousResults.results[category] || { synced: 0, complete: false }; - const curr = currentResult.results[category] || { synced: 0, complete: false }; - - mergedResults.results[category] = { - synced: prev.synced + curr.synced, - complete: curr.complete, - }; - } - - if (!currentResult.complete) { - console.log(`Sync not complete, recursing. Current recursion count: ${recursionCount}`); - return internalSyncSharedData(board_name, token, mergedResults, recursionCount + 1); - } - - console.log(`Sync complete. Returning merged results.`, currentResult); - return mergedResults; -}; - -export async function GET(request: Request, props: { params: Promise }) { - const params = await props.params; - try { - const { board_name: boardNameParam } = params; - - // Validate board_name is a valid AuroraBoardName - if (!AURORA_BOARD_NAMES.includes(boardNameParam as AuroraBoardName)) { - return NextResponse.json({ error: `Invalid board name: ${boardNameParam}` }, { status: 400 }); - } - const board_name = boardNameParam as AuroraBoardName; - - console.log(`Starting shared sync for ${board_name}`); - - // Auth check - always require valid CRON_SECRET - const authHeader = request.headers.get('authorization'); - if (!CRON_SECRET || authHeader !== `Bearer ${CRON_SECRET}`) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const AURORA_TOKENS: Record = { - kilter: process.env.KILTER_SYNC_TOKEN, - tension: process.env.TENSION_SYNC_TOKEN, - }; - // Get the token for this board - const token = AURORA_TOKENS && AURORA_TOKENS[board_name]; - if (!token) { - console.error( - `No sync token configured for ${board_name}. Set ${board_name.toUpperCase()}_SYNC_TOKEN env variable.`, - ); - return NextResponse.json({ error: `No sync token configured for ${board_name}` }, { status: 500 }); - } - - const result = await internalSyncSharedData(board_name, token); - - return NextResponse.json({ - success: true, - results: result, - complete: result.complete, - }); - } catch (error) { - console.error('Cron job failed:', error); - return NextResponse.json({ success: false, error: 'Sync failed' }, { status: 500 }); - } -} diff --git a/packages/web/app/api/internal/user-board-mapping/route.ts b/packages/web/app/api/internal/user-board-mapping/route.ts deleted file mode 100644 index a7cff26c..00000000 --- a/packages/web/app/api/internal/user-board-mapping/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { getServerSession } from "next-auth/next"; -import { NextRequest, NextResponse } from "next/server"; -import { createUserBoardMapping, getUserBoardMappings } from "@/app/lib/auth/user-board-mappings"; -import { authOptions } from "@/app/lib/auth/auth-options"; -import { z } from "zod"; - -const userBoardMappingSchema = z.object({ - boardType: z.enum(["kilter", "tension"], { - message: "Board type must be kilter or tension", - }), - boardUserId: z.number().int().positive("Board user ID must be a positive integer"), - boardUsername: z.string().max(100, "Username too long").optional(), -}); - -export async function POST(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const result = userBoardMappingSchema.safeParse(body); - - if (!result.success) { - return NextResponse.json({ error: "Invalid request data" }, { status: 400 }); - } - - const { boardType, boardUserId, boardUsername } = result.data; - - await createUserBoardMapping( - session.user.id, - boardType, - boardUserId, - boardUsername - ); - - return NextResponse.json({ success: true }); - } catch (error) { - console.error("Failed to create board mapping:", error); - return NextResponse.json( - { error: "Failed to create board mapping" }, - { status: 500 } - ); - } -} - -export async function GET() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const mappings = await getUserBoardMappings(session.user.id); - return NextResponse.json({ mappings }); - } catch (error) { - console.error("Failed to get board mappings:", error); - return NextResponse.json( - { error: "Failed to get board mappings" }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/packages/web/app/api/internal/user-sync-cron/route.ts b/packages/web/app/api/internal/user-sync-cron/route.ts deleted file mode 100644 index 9324c99c..00000000 --- a/packages/web/app/api/internal/user-sync-cron/route.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { NextResponse } from 'next/server'; -import { syncUserData } from '@/app/lib/data-sync/aurora/user-sync'; -import { getPool } from '@/app/lib/db/db'; -import { drizzle } from 'drizzle-orm/neon-serverless'; -import { eq, and, or, isNotNull, asc } from 'drizzle-orm'; -import { decrypt, encrypt } from '@boardsesh/crypto'; -import * as schema from '@/app/lib/db/schema'; -import AuroraClimbingClient from '@/app/lib/api-wrappers/aurora-rest-client/aurora-rest-client'; -import { BoardName as AuroraBoardName } from '@/app/lib/api-wrappers/aurora-rest-client/types'; - -export const dynamic = 'force-dynamic'; -export const maxDuration = 300; // 5 minutes max - -const CRON_SECRET = process.env.CRON_SECRET; - -interface SyncResult { - userId: string; - boardType: string; - error?: string; -} - -export async function GET(request: Request) { - try { - // Auth check - const authHeader = request.headers.get('authorization'); - if (process.env.VERCEL_ENV !== 'development' && authHeader !== `Bearer ${CRON_SECRET}`) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const pool = getPool(); - - // Get ONE credential to sync - prioritize users who haven't synced longest (NULLS FIRST) - // Include both 'active' and 'error' status to retry failed syncs - let credentials; - { - const client = await pool.connect(); - try { - const db = drizzle(client); - credentials = await db - .select() - .from(schema.auroraCredentials) - .where( - and( - or( - eq(schema.auroraCredentials.syncStatus, 'active'), - eq(schema.auroraCredentials.syncStatus, 'error') - ), - isNotNull(schema.auroraCredentials.encryptedUsername), - isNotNull(schema.auroraCredentials.encryptedPassword), - isNotNull(schema.auroraCredentials.auroraUserId) - ) - ) - .orderBy(asc(schema.auroraCredentials.lastSyncAt)) // NULLS FIRST is default in PostgreSQL - .limit(1); - } finally { - client.release(); - } - } - - if (credentials.length === 0) { - console.log('[User Sync Cron] No users to sync'); - return NextResponse.json({ - success: true, - results: { total: 0, successful: 0, failed: 0, errors: [] }, - timestamp: new Date().toISOString(), - }); - } - - console.log(`[User Sync Cron] Syncing 1 user (oldest lastSyncAt): ${credentials[0].userId} (${credentials[0].boardType})`); - - const results = { - total: credentials.length, - successful: 0, - failed: 0, - errors: [] as SyncResult[], - }; - - // Sync each user sequentially (to avoid overwhelming Aurora API) - // Each iteration acquires its own connections as needed - for (const cred of credentials) { - try { - if (!cred.encryptedUsername || !cred.encryptedPassword || !cred.auroraUserId) { - console.warn(`[User Sync Cron] Skipping user ${cred.userId} (${cred.boardType}): Missing credentials or user ID`); - continue; - } - - const boardType = cred.boardType as AuroraBoardName; - - // Decrypt credentials and get a fresh token - let token: string; - let username: string; - let password: string; - try { - username = decrypt(cred.encryptedUsername); - password = decrypt(cred.encryptedPassword); - } catch (decryptError) { - const errorMsg = `Failed to decrypt credentials: ${decryptError instanceof Error ? decryptError.message : 'Unknown error'}`; - console.error(`[User Sync Cron] ${errorMsg} for user ${cred.userId} (${cred.boardType})`); - - results.failed++; - results.errors.push({ - userId: cred.userId, - boardType: cred.boardType, - error: errorMsg, - }); - - // Update status to error - const updateClient = await pool.connect(); - try { - const updateDb = drizzle(updateClient); - await updateDb - .update(schema.auroraCredentials) - .set({ - syncStatus: 'error', - syncError: errorMsg, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.auroraCredentials.userId, cred.userId), - eq(schema.auroraCredentials.boardType, cred.boardType) - ) - ); - } finally { - updateClient.release(); - } - - continue; - } - - // Get a fresh token by logging in - console.log(`[User Sync Cron] Getting fresh token for user ${cred.userId} (${boardType})...`); - const auroraClient = new AuroraClimbingClient({ boardName: boardType }); - let loginResponse; - try { - loginResponse = await auroraClient.signIn(username, password); - } catch (loginError) { - const errorMsg = `Failed to login: ${loginError instanceof Error ? loginError.message : 'Unknown error'}`; - console.error(`[User Sync Cron] ${errorMsg} for user ${cred.userId} (${cred.boardType})`); - - results.failed++; - results.errors.push({ - userId: cred.userId, - boardType: cred.boardType, - error: errorMsg, - }); - - // Update status to error - const updateClient = await pool.connect(); - try { - const updateDb = drizzle(updateClient); - await updateDb - .update(schema.auroraCredentials) - .set({ - syncStatus: 'error', - syncError: errorMsg, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.auroraCredentials.userId, cred.userId), - eq(schema.auroraCredentials.boardType, cred.boardType) - ) - ); - } finally { - updateClient.release(); - } - - continue; - } - - if (!loginResponse.token) { - const errorMsg = 'Login succeeded but no token returned'; - console.error(`[User Sync Cron] ${errorMsg} for user ${cred.userId} (${cred.boardType})`); - results.failed++; - results.errors.push({ userId: cred.userId, boardType: cred.boardType, error: errorMsg }); - continue; - } - - token = loginResponse.token; - - // Update the stored token - const encryptedToken = encrypt(token); - const tokenUpdateClient = await pool.connect(); - try { - const tokenUpdateDb = drizzle(tokenUpdateClient); - await tokenUpdateDb - .update(schema.auroraCredentials) - .set({ - auroraToken: encryptedToken, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.auroraCredentials.userId, cred.userId), - eq(schema.auroraCredentials.boardType, cred.boardType) - ) - ); - } finally { - tokenUpdateClient.release(); - } - - // Wait for Aurora session replication across their backend servers - // Testing if this fixes the 404 errors on Vercel (works locally) - console.log('[User Sync Cron] Waiting 5 seconds for Aurora session replication...'); - await new Promise(resolve => setTimeout(resolve, 5000)); - - console.log(`[User Sync Cron] Syncing user ${cred.userId} for ${boardType}...`); - - // syncUserData manages its own connections internally - await syncUserData(boardType, token, cred.auroraUserId); - - // Update last sync time on success - acquire new connection - const updateClient = await pool.connect(); - try { - const updateDb = drizzle(updateClient); - await updateDb - .update(schema.auroraCredentials) - .set({ - lastSyncAt: new Date(), - syncStatus: 'active', - syncError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.auroraCredentials.userId, cred.userId), - eq(schema.auroraCredentials.boardType, boardType) - ) - ); - } finally { - updateClient.release(); - } - - results.successful++; - console.log(`[User Sync Cron] ✓ Successfully synced user ${cred.userId} for ${boardType}`); - } catch (error) { - results.failed++; - const errorMsg = error instanceof Error ? error.message : 'Unknown error'; - results.errors.push({ - userId: cred.userId, - boardType: cred.boardType, - error: errorMsg, - }); - - // Update sync status to error - acquire new connection - const updateClient = await pool.connect(); - try { - const updateDb = drizzle(updateClient); - await updateDb - .update(schema.auroraCredentials) - .set({ - syncStatus: 'error', - syncError: errorMsg, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.auroraCredentials.userId, cred.userId), - eq(schema.auroraCredentials.boardType, cred.boardType) - ) - ); - } catch (updateError) { - console.error(`[User Sync Cron] Failed to update error status for user ${cred.userId}:`, updateError); - } finally { - updateClient.release(); - } - - console.error(`[User Sync Cron] ✗ Failed to sync user ${cred.userId} for ${cred.boardType}:`, errorMsg); - } - } - - return NextResponse.json({ - success: true, - results, - timestamp: new Date().toISOString(), - }); - } catch (error) { - console.error('[User Sync Cron] Cron job failed:', error); - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }, - { status: 500 } - ); - } -} diff --git a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts b/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts deleted file mode 100644 index d6d72e78..00000000 --- a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/heatmap/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { getHoldHeatmapData, HoldHeatmapData } from '@/app/lib/db/queries/climbs/holds-heatmap'; -import { BoardRouteParameters, ErrorResponse, ParsedBoardRouteParameters, SearchRequestPagination } from '@/app/lib/types'; -import { urlParamsToSearchParams } from '@/app/lib/url-utils'; -import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; -import { sortObjectKeys } from '@/app/lib/cache-utils'; -import { unstable_cache } from 'next/cache'; -import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth/next'; -import { authOptions } from '@/app/lib/auth/auth-options'; - -/** - * Cache duration for heatmap queries (in seconds) - * Anonymous heatmap queries are cached for 30 days since aggregate data doesn't change meaningfully - */ -const CACHE_DURATION_HEATMAP = 30 * 24 * 60 * 60; // 30 days - -/** - * Cached version of getHoldHeatmapData - * Only used for anonymous requests - user-specific data is not cached - */ -async function cachedGetHoldHeatmapData( - params: ParsedBoardRouteParameters, - searchParams: SearchRequestPagination, -): Promise { - // Build explicit cache key with board identifiers as separate segments - // This ensures cache hits/misses are correctly differentiated by board configuration - const cacheKey = [ - 'heatmap', - params.board_name, - String(params.layout_id), - String(params.size_id), - params.set_ids.join(','), - String(params.angle), - // Include filter params as a sorted JSON string - JSON.stringify(sortObjectKeys({ - gradeAccuracy: searchParams.gradeAccuracy, - minGrade: searchParams.minGrade, - maxGrade: searchParams.maxGrade, - minAscents: searchParams.minAscents, - minRating: searchParams.minRating, - sortBy: searchParams.sortBy, - sortOrder: searchParams.sortOrder, - name: searchParams.name, - settername: searchParams.settername, - onlyClassics: searchParams.onlyClassics, - onlyTallClimbs: searchParams.onlyTallClimbs, - holdsFilter: searchParams.holdsFilter, - })), - ]; - - const cachedFn = unstable_cache( - async () => getHoldHeatmapData(params, searchParams, undefined), - cacheKey, - { - revalidate: CACHE_DURATION_HEATMAP, - tags: ['heatmap'], - } - ); - - return cachedFn(); -} - -export interface HoldHeatmapResponse { - holdStats: Array<{ - holdId: number; - totalUses: number; - startingUses: number; - totalAscents: number; - handUses: number; - footUses: number; - finishUses: number; - averageDifficulty: number | null; - userAscents?: number; // Added for user-specific ascent data - userAttempts?: number; // Added for user-specific attempt data - }>; -} - -export async function GET( - req: Request, - props: { params: Promise }, -): Promise> { - const params = await props.params; - // Extract search parameters from query string - const query = new URL(req.url).searchParams; - - try { - const parsedParams = await parseBoardRouteParamsWithSlugs(params); - - // MoonBoard doesn't have database tables for heatmap - return empty results - if (parsedParams.board_name === 'moonboard') { - return NextResponse.json({ - holdStats: [], - }); - } - - const searchParams: SearchRequestPagination = urlParamsToSearchParams(query); - - // Get NextAuth session for user-specific data - const session = await getServerSession(authOptions); - const userId = session?.user?.id; - - // Get the heatmap data - use cached version for anonymous requests only - // User-specific data is not cached to ensure fresh personal progress data - const holdStats = userId - ? await getHoldHeatmapData(parsedParams, searchParams, userId) - : await cachedGetHoldHeatmapData(parsedParams, searchParams); - - // Return response - return NextResponse.json({ - holdStats, - }); - } catch (error) { - console.error('Error generating heatmap data:', error); - return NextResponse.json({ error: 'Failed to generate hold heatmap data' }, { status: 500 }); - } -} diff --git a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts b/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts deleted file mode 100644 index 9cf5b9dc..00000000 --- a/packages/web/app/api/v1/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/setters/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getSetterStats, SetterStat } from '@/app/lib/db/queries/climbs/setter-stats'; -import { BoardRouteParameters, ErrorResponse } from '@/app/lib/types'; -import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; -import { NextResponse } from 'next/server'; - -export async function GET( - req: Request, - props: { params: Promise }, -): Promise> { - const params = await props.params; - - try { - const parsedParams = await parseBoardRouteParamsWithSlugs(params); - - // MoonBoard doesn't have database tables for setter stats - return empty results - if (parsedParams.board_name === 'moonboard') { - return NextResponse.json([]); - } - - // Extract search query parameter - const url = new URL(req.url); - const searchQuery = url.searchParams.get('search') || undefined; - - const setterStats = await getSetterStats(parsedParams, searchQuery); - - return NextResponse.json(setterStats); - } catch (error) { - console.error('Error fetching setter stats:', error); - return NextResponse.json({ error: 'Failed to fetch setter stats' }, { status: 500 }); - } -} diff --git a/packages/web/app/api/v1/[board_name]/beta/[climb_uuid]/route.ts b/packages/web/app/api/v1/[board_name]/beta/[climb_uuid]/route.ts deleted file mode 100644 index bdb99c10..00000000 --- a/packages/web/app/api/v1/[board_name]/beta/[climb_uuid]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { dbz } from '@/app/lib/db/db'; -import { eq, and } from 'drizzle-orm'; -import { BoardName } from '@/app/lib/types'; -import { extractUuidFromSlug } from '@/app/lib/url-utils'; -import { UNIFIED_TABLES, isValidUnifiedBoardName } from '@/app/lib/db/queries/util/table-select'; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ board_name: string; climb_uuid: string }> }, -) { - const { board_name: boardNameParam, climb_uuid: rawClimbUuid } = await params; - const board_name = boardNameParam as BoardName; - const climb_uuid = extractUuidFromSlug(rawClimbUuid); - - if (!isValidUnifiedBoardName(board_name)) { - return NextResponse.json({ error: 'Invalid board name' }, { status: 400 }); - } - - try { - const { betaLinks } = UNIFIED_TABLES; - - const results = await dbz - .select() - .from(betaLinks) - .where(and(eq(betaLinks.boardType, board_name), eq(betaLinks.climbUuid, climb_uuid))); - - // Transform the database results to match the BetaLink interface - const transformedLinks = results.map((link) => ({ - climb_uuid: link.climbUuid, - link: link.link, - foreign_username: link.foreignUsername, - angle: link.angle, - thumbnail: link.thumbnail, - is_listed: link.isListed, - created_at: link.createdAt, - })); - - return NextResponse.json(transformedLinks); - } catch (error) { - console.error('Error fetching beta links:', error); - return NextResponse.json({ error: 'Failed to fetch beta links' }, { status: 500 }); - } -} diff --git a/packages/web/app/api/v1/[board_name]/climb-stats/[climb_uuid]/route.ts b/packages/web/app/api/v1/[board_name]/climb-stats/[climb_uuid]/route.ts deleted file mode 100644 index 32568631..00000000 --- a/packages/web/app/api/v1/[board_name]/climb-stats/[climb_uuid]/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getClimbStatsForAllAngles, ClimbStatsForAngle } from '@/app/lib/data/queries'; -import { ErrorResponse, BoardName } from '@/app/lib/types'; -import { NextResponse } from 'next/server'; - -export async function GET( - req: Request, - props: { params: Promise<{ board_name: string; climb_uuid: string }> }, -): Promise> { - const params = await props.params; - try { - // Create a minimal parsed params object with just what we need - const parsedParams = { - board_name: params.board_name as BoardName, - climb_uuid: params.climb_uuid, - // These aren't needed for the climb stats query, but required by the interface - layout_id: 0, - size_id: 0, - set_ids: [] as number[], - angle: 0, - }; - - const climbStats = await getClimbStatsForAllAngles(parsedParams); - - return NextResponse.json(climbStats); - } catch (error) { - console.error('Error fetching climb stats:', error); - return NextResponse.json({ error: 'Failed to fetch climb stats' }, { status: 500 }); - } -} \ No newline at end of file diff --git a/packages/web/app/api/v1/[board_name]/proxy/getLogbook/route.ts b/packages/web/app/api/v1/[board_name]/proxy/getLogbook/route.ts deleted file mode 100644 index 761053c7..00000000 --- a/packages/web/app/api/v1/[board_name]/proxy/getLogbook/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -// app/api/login/route.ts -import { getLogbook } from '@/app/lib/data/get-logbook'; -import { getSession } from '@/app/lib/session'; -import { BoardOnlyRouteParameters, BoardName } from '@/app/lib/types'; -import { AuroraBoardName } from '@/app/lib/api-wrappers/aurora/types'; -import { cookies } from 'next/headers'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; - -export async function POST(request: Request, props: { params: Promise }) { - const params = await props.params; - - // MoonBoard doesn't use Aurora APIs - if (params.board_name === 'moonboard') { - return NextResponse.json({ error: 'MoonBoard does not support this endpoint' }, { status: 400 }); - } - - const board_name = params.board_name as AuroraBoardName; - try { - // Parse and validate request body - const validatedData = await request.json(); - // Call the board API - const cookieStore = await cookies(); - const session = await getSession(cookieStore, board_name); - - const { token, userId } = session; - - if (!token || !userId) { - throw new Error('401: Unauthorized'); - } - - const response = await getLogbook(board_name, validatedData.userId, validatedData.climbUuids); - - return NextResponse.json(response); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request data', details: error.issues }, { status: 400 }); - } - - // Handle fetch errors - if (error instanceof Error) { - if (error.message.includes('401')) { - return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); - } - - if (error.message.includes('403')) { - return NextResponse.json({ error: 'Access forbidden' }, { status: 403 }); - } - - if (error.message.startsWith('HTTP error!')) { - return NextResponse.json({ error: 'Service unavailable' }, { status: 503 }); - } - } - - // Generic error - console.error('Login error:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/packages/web/app/api/v1/[board_name]/proxy/login/route.ts b/packages/web/app/api/v1/[board_name]/proxy/login/route.ts deleted file mode 100644 index 0d8b63ef..00000000 --- a/packages/web/app/api/v1/[board_name]/proxy/login/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { dbz } from '@/app/lib/db/db'; -import { boardUsers } from '@/app/lib/db/schema'; - -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import AuroraClimbingClient from '@/app/lib/api-wrappers/aurora-rest-client/aurora-rest-client'; -import { BoardOnlyRouteParameters } from '@/app/lib/types'; -import { syncUserData } from '@/app/lib/data-sync/aurora/user-sync'; -import { Session, BoardName as AuroraBoardName } from '@/app/lib/api-wrappers/aurora-rest-client/types'; -import { getSession } from '@/app/lib/session'; -import { isAuroraBoardName } from '@/app/lib/board-constants'; - -// Input validation schema -const loginSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1), -}); - -/** - * Performs login for a specific climbing board - * @param board - The name of the climbing board - * @param username - User's username - * @param password - User's password - * @returns Login response from the board's API - */ -async function login(boardName: AuroraBoardName, username: string, password: string): Promise { - const auroraClient = new AuroraClimbingClient({ boardName: boardName }); - const loginResponse = await auroraClient.signIn(username, password); - - if (!loginResponse.token || !loginResponse.user_id) { - throw new Error('Invalid login response: missing token or user_id'); - } - - if (loginResponse.user_id) { - // Insert/update user in our database - handle missing user object - const createdAt = loginResponse.user?.created_at ? new Date(loginResponse.user.created_at).toISOString() : new Date().toISOString(); - - await dbz - .insert(boardUsers) - .values({ - boardType: boardName, - id: loginResponse.user_id, - username: loginResponse.username || username, - createdAt, - }) - .onConflictDoUpdate({ - target: [boardUsers.boardType, boardUsers.id], - set: { username: loginResponse.username || username }, - }); - - // If it's a new user, perform full sync - try { - await syncUserData(boardName, loginResponse.token, loginResponse.user_id); - } catch (error) { - console.error('Initial sync error:', error); - // We don't throw here as login was successful - } - } - - // Convert LoginResponse to Session - return { - token: loginResponse.token, - user_id: loginResponse.user_id, - }; -} - -/** - * Route handler for login POST requests - * @param request - Incoming HTTP request - * @param props - Route parameters - * @returns NextResponse with login results or error - */ -export async function POST(request: Request, props: { params: Promise }) { - const params = await props.params; - - // Only kilter and tension use Aurora APIs - if (!isAuroraBoardName(params.board_name)) { - return NextResponse.json({ error: 'Unsupported board for this endpoint' }, { status: 400 }); - } - - const board_name = params.board_name as AuroraBoardName; - - try { - // Parse and validate request body - const body = await request.json(); - const validatedData = loginSchema.parse(body); - - // Call the board API - const loginResponse = await login(board_name, validatedData.username, validatedData.password); - - const response = NextResponse.json(loginResponse); - - const session = await getSession(response.cookies, board_name); - session.token = loginResponse.token; - session.username = validatedData.username; - session.userId = loginResponse.user_id; - await session.save(); - - return response; - } catch (error) { - if (error instanceof z.ZodError) { - console.error('Login validation error:', error.issues); - return NextResponse.json({ error: 'Invalid request data' }, { status: 400 }); - } - - // Handle fetch errors - if (error instanceof Error) { - if (error.message.includes('401')) { - return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); - } - if (error.message.includes('403')) { - return NextResponse.json({ error: 'Access forbidden' }, { status: 403 }); - } - if (error.message.startsWith('HTTP error!')) { - return NextResponse.json({ error: 'Service unavailable' }, { status: 503 }); - } - } - - // Generic error - console.error('Login error:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/packages/web/app/api/v1/[board_name]/proxy/saveAscent/route.ts b/packages/web/app/api/v1/[board_name]/proxy/saveAscent/route.ts deleted file mode 100644 index 69526986..00000000 --- a/packages/web/app/api/v1/[board_name]/proxy/saveAscent/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -// app/api/v1/[board_name]/proxy/saveAscent/route.ts -import { saveAscent } from '@/app/lib/api-wrappers/aurora/saveAscent'; -import { AuroraBoardName } from '@/app/lib/api-wrappers/aurora/types'; -import { BoardOnlyRouteParameters } from '@/app/lib/types'; -import { NextResponse } from 'next/server'; -import { z } from 'zod'; -import { getServerSession } from 'next-auth/next'; -import { authOptions } from '@/app/lib/auth/auth-options'; - -const saveAscentSchema = z.object({ - token: z.string().min(1), - options: z - .object({ - uuid: z.string(), - user_id: z.number(), // Legacy Aurora user_id (not used for storage anymore) - climb_uuid: z.string(), - angle: z.number(), - is_mirror: z.boolean(), - attempt_id: z.number(), - bid_count: z.number(), - quality: z.number(), - difficulty: z.number(), - is_benchmark: z.boolean(), - comment: z.string(), - climbed_at: z.string(), - }) - .strict(), -}); - -export async function POST(request: Request, props: { params: Promise }) { - const params = await props.params; - - // MoonBoard doesn't use Aurora APIs - if (params.board_name === 'moonboard') { - return NextResponse.json({ error: 'MoonBoard does not support this endpoint' }, { status: 400 }); - } - - const board_name = params.board_name as AuroraBoardName; - - // Get NextAuth session - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); - } - - try { - const body = await request.json(); - const validatedData = saveAscentSchema.parse(body); - - // saveAscent now writes to boardsesh_ticks using NextAuth userId - const response = await saveAscent(board_name, validatedData.token, validatedData.options, session.user.id); - return NextResponse.json(response); - } catch (error) { - console.error('SaveAscent error details:', { - error: error instanceof Error ? error.message : error, - stack: error instanceof Error ? error.stack : undefined, - board_name, - }); - - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request data', details: error.issues }, { status: 400 }); - } - - // Only database errors should reach here now - return NextResponse.json({ error: 'Failed to save ascent' }, { status: 500 }); - } -} diff --git a/packages/web/app/api/v1/[board_name]/proxy/user-sync/route.ts b/packages/web/app/api/v1/[board_name]/proxy/user-sync/route.ts deleted file mode 100644 index db9b95b2..00000000 --- a/packages/web/app/api/v1/[board_name]/proxy/user-sync/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -// app/api/[board]/sync/route.ts -import { syncUserData } from '@/app/lib/data-sync/aurora/user-sync'; -import { getSession } from '@/app/lib/session'; -import { cookies } from 'next/headers'; - -export async function POST(request: Request) { - const { board_name } = await request.json(); - - try { - const cookieStore = await cookies(); - const session = await getSession(cookieStore, board_name); - if (!session) { - throw new Error('401: Unauthorized'); - } - const { token, userId } = session; - await syncUserData(board_name, token, userId); - return new Response(JSON.stringify({ success: true, message: 'All tables synced' }), { status: 200 }); - } catch (err) { - console.error('Failed to sync with Aurora:', err); - //@ts-expect-error Eh cant be bothered fixing this now - return new Response(JSON.stringify({ error: 'Sync failed', details: err.message }), { status: 500 }); - } -} diff --git a/packages/web/app/components/beta-videos/beta-videos.tsx b/packages/web/app/components/beta-videos/beta-videos.tsx index 8abfd2a7..f470cf2e 100644 --- a/packages/web/app/components/beta-videos/beta-videos.tsx +++ b/packages/web/app/components/beta-videos/beta-videos.tsx @@ -13,7 +13,7 @@ import CardContent from '@mui/material/CardContent'; import { Instagram, PersonOutlined, ExpandLessOutlined } from '@mui/icons-material'; import ExpandMoreOutlined from '@mui/icons-material/ExpandMoreOutlined'; import { EmptyState } from '@/app/components/ui/empty-state'; -import { BetaLink } from '@/app/lib/api-wrappers/sync-api-types'; +import type { BetaLink } from '@boardsesh/shared-schema'; import { themeTokens } from '@/app/theme/theme-config'; interface BetaVideosProps { @@ -79,7 +79,7 @@ const BetaVideos: React.FC = ({ betaLinks }) => { pointerEvents: 'none', }} scrolling="no" - title={`Beta video by ${betaLink.foreign_username || 'unknown'}`} + title={`Beta video by ${betaLink.foreignUsername || 'unknown'}`} /> ) : ( @@ -105,9 +105,9 @@ const BetaVideos: React.FC = ({ betaLinks }) => { borderTop: `1px solid var(--neutral-100)`, }} > - {betaLink.foreign_username && ( + {betaLink.foreignUsername && ( - @{betaLink.foreign_username} + @{betaLink.foreignUsername} {betaLink.angle && {betaLink.angle}°} )} @@ -170,7 +170,7 @@ const BetaVideos: React.FC = ({ betaLinks }) => { sx={{ '& .MuiDialog-paper': { maxWidth: '500px', width: '90%' } }} > - {selectedVideo?.foreign_username ? `Beta by @${selectedVideo.foreign_username}` : 'Beta Video'} + {selectedVideo?.foreign_username ? `Beta by @${selectedVideo.foreignUsername}` : 'Beta Video'} {selectedVideo && ( diff --git a/packages/web/app/components/board-page/angle-selector.tsx b/packages/web/app/components/board-page/angle-selector.tsx index d5e5df87..d34114c6 100644 --- a/packages/web/app/components/board-page/angle-selector.tsx +++ b/packages/web/app/components/board-page/angle-selector.tsx @@ -13,8 +13,10 @@ import { track } from '@vercel/analytics'; import { useQuery } from '@tanstack/react-query'; import { ANGLES } from '@/app/lib/board-data'; import { BoardName, BoardDetails, Climb } from '@/app/lib/types'; -import { ClimbStatsForAngle } from '@/app/lib/data/queries'; +import type { ClimbStatsForAngle } from '@boardsesh/shared-schema'; import { themeTokens } from '@/app/theme/theme-config'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { GET_CLIMB_STATS_FOR_ALL_ANGLES, type GetClimbStatsForAllAnglesQueryResponse, type GetClimbStatsForAllAnglesQueryVariables } from '@/app/lib/graphql/operations'; import DrawerClimbHeader from '../climb-card/drawer-climb-header'; import styles from './angle-selector.module.css'; @@ -35,7 +37,14 @@ export default function AngleSelector({ boardName, boardDetails, currentAngle, c // Fetch climb stats for all angles when there's a current climb const { data: climbStats, isLoading } = useQuery({ queryKey: ['climbStats', boardName, currentClimb?.uuid], - queryFn: () => fetch(`/api/v1/${boardName}/climb-stats/${currentClimb!.uuid}`).then(res => res.json()), + queryFn: async () => { + if (!currentClimb) return []; + const data = await executeGraphQL( + GET_CLIMB_STATS_FOR_ALL_ANGLES, + { boardName, climbUuid: currentClimb.uuid }, + ); + return data.climbStatsForAllAngles; + }, enabled: !!currentClimb, staleTime: 5 * 60 * 1000, }); @@ -118,13 +127,13 @@ export default function AngleSelector({ boardName, boardDetails, currentAngle, c {stats.difficulty} )} - {stats.quality_average !== null && Number(stats.quality_average) > 0 && ( + {stats.qualityAverage !== null && Number(stats.qualityAverage) > 0 && ( - ★{Number(stats.quality_average).toFixed(1)} + ★{Number(stats.qualityAverage).toFixed(1)} )} - {stats.ascensionist_count} sends + {stats.ascensionistCount} sends diff --git a/packages/web/app/components/climb-actions/actions/favorite-action.tsx b/packages/web/app/components/climb-actions/actions/favorite-action.tsx index ada416c3..4ecc4ddf 100644 --- a/packages/web/app/components/climb-actions/actions/favorite-action.tsx +++ b/packages/web/app/components/climb-actions/actions/favorite-action.tsx @@ -10,6 +10,9 @@ import { useFavorite } from '../use-favorite'; import AuthModal from '../../auth/auth-modal'; import { themeTokens } from '@/app/theme/theme-config'; import { buildActionResult, computeActionDisplay } from '../action-view-renderer'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { TOGGLE_FAVORITE, type ToggleFavoriteMutationVariables, type ToggleFavoriteMutationResponse } from '@/app/lib/graphql/operations'; export function FavoriteAction({ climb, @@ -23,6 +26,7 @@ export function FavoriteAction({ onComplete, }: ClimbActionProps): ClimbActionResult { const [showAuthModal, setShowAuthModal] = useState(false); + const { token: authToken } = useWsAuthToken(); const { iconSize } = computeActionDisplay(viewMode, size, showLabel); const { isFavorited, isLoading, toggleFavorite, isAuthenticated } = useFavorite({ @@ -53,16 +57,12 @@ export function FavoriteAction({ const handleAuthSuccess = useCallback(async () => { try { - const response = await fetch('/api/internal/favorites', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - boardName: boardDetails.board_name, - climbUuid: climb.uuid, - angle, - }), - }); - if (response.ok) { + const data = await executeGraphQL( + TOGGLE_FAVORITE, + { input: { boardName: boardDetails.board_name, climbUuid: climb.uuid, angle } }, + authToken, + ); + if (data.toggleFavorite.favorited !== undefined) { track('Favorite Toggle', { boardName: boardDetails.board_name, climbUuid: climb.uuid, @@ -72,7 +72,7 @@ export function FavoriteAction({ } catch { // Silently fail } - }, [boardDetails.board_name, climb.uuid, angle]); + }, [boardDetails.board_name, climb.uuid, angle, authToken]); const label = isFavorited ? 'Favorited' : 'Favorite'; const HeartIcon = isFavorited ? Favorite : FavoriteBorderOutlined; diff --git a/packages/web/app/components/climb-actions/favorite-button.tsx b/packages/web/app/components/climb-actions/favorite-button.tsx index 9e1054b1..d2c2cb3a 100644 --- a/packages/web/app/components/climb-actions/favorite-button.tsx +++ b/packages/web/app/components/climb-actions/favorite-button.tsx @@ -10,6 +10,9 @@ import { useFavorite } from './use-favorite'; import { BoardName } from '@/app/lib/types'; import AuthModal from '../auth/auth-modal'; import { themeTokens } from '@/app/theme/theme-config'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { TOGGLE_FAVORITE, type ToggleFavoriteMutationVariables, type ToggleFavoriteMutationResponse } from '@/app/lib/graphql/operations'; type FavoriteButtonProps = { boardName: BoardName; @@ -34,6 +37,7 @@ export default function FavoriteButton({ climbUuid, }); const { showMessage } = useSnackbar(); + const { token: authToken } = useWsAuthToken(); const [showAuthModal, setShowAuthModal] = useState(false); @@ -61,26 +65,18 @@ export default function FavoriteButton({ }; const handleAuthSuccess = async () => { - // Call API directly since session state may not have updated yet try { - const response = await fetch('/api/internal/favorites', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - boardName, - climbUuid, - angle, - }), - }); - if (response.ok) { + const data = await executeGraphQL( + TOGGLE_FAVORITE, + { input: { boardName, climbUuid, angle } }, + authToken, + ); + if (data.toggleFavorite.favorited !== undefined) { track('Favorite Toggle', { boardName, climbUuid, action: 'favorited', }); - } else { - console.error(`[FavoriteButton] API error for ${climbUuid}: ${response.status}`); - showMessage('Failed to save favorite. Please try again.', 'error'); } } catch (error) { console.error(`[FavoriteButton] Error after auth for ${climbUuid}:`, error); diff --git a/packages/web/app/components/climb-detail/build-climb-detail-sections.tsx b/packages/web/app/components/climb-detail/build-climb-detail-sections.tsx index 5c395887..79773910 100644 --- a/packages/web/app/components/climb-detail/build-climb-detail-sections.tsx +++ b/packages/web/app/components/climb-detail/build-climb-detail-sections.tsx @@ -6,8 +6,10 @@ import type { CollapsibleSectionConfig } from '@/app/components/collapsible-sect import BetaVideos from '@/app/components/beta-videos/beta-videos'; import { LogbookSection, useLogbookSummary } from '@/app/components/logbook/logbook-section'; import ClimbSocialSection from '@/app/components/social/climb-social-section'; -import type { BetaLink } from '@/app/lib/api-wrappers/sync-api-types'; +import type { BetaLink } from '@boardsesh/shared-schema'; import type { Climb } from '@/app/lib/types'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { GET_BETA_LINKS, type GetBetaLinksQueryResponse, type GetBetaLinksQueryVariables } from '@/app/lib/graphql/operations'; interface BuildClimbDetailSectionsProps { climb: Climb; @@ -37,14 +39,12 @@ function useClimbBetaLinks({ boardType, climbUuid, initialBetaLinks }: { boardTy const fetchBetaLinks = async () => { try { - const response = await fetch(`/api/v1/${boardType}/beta/${climbUuid}`); - if (!response.ok) { - return; - } - - const data: BetaLink[] = await response.json(); + const data = await executeGraphQL( + GET_BETA_LINKS, + { boardName: boardType, climbUuid }, + ); if (!cancelled) { - setBetaLinks(data); + setBetaLinks(data.betaLinks); } } catch { if (!cancelled) { diff --git a/packages/web/app/components/hold-classification/hold-classification-wizard.tsx b/packages/web/app/components/hold-classification/hold-classification-wizard.tsx index 92415caa..225ea98a 100644 --- a/packages/web/app/components/hold-classification/hold-classification-wizard.tsx +++ b/packages/web/app/components/hold-classification/hold-classification-wizard.tsx @@ -7,6 +7,16 @@ import Rating from '@mui/material/Rating'; import LinearProgress from '@mui/material/LinearProgress'; import CircularProgress from '@mui/material/CircularProgress'; import { useSnackbar } from '@/app/components/providers/snackbar-provider'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { + GET_HOLD_CLASSIFICATIONS, + SAVE_HOLD_CLASSIFICATION, + type GetHoldClassificationsQueryResponse, + type GetHoldClassificationsQueryVariables, + type SaveHoldClassificationMutationResponse, + type SaveHoldClassificationMutationVariables, +} from '@/app/lib/graphql/operations'; import SwipeableDrawer from '../swipeable-drawer/swipeable-drawer'; import { ArrowBackOutlined, ArrowForwardOutlined, CheckOutlined, CheckCircle, OpenInFullOutlined, CompressOutlined } from '@mui/icons-material'; import { useSession } from 'next-auth/react'; @@ -111,6 +121,7 @@ const HoldClassificationWizard: React.FC = ({ }) => { const { status: sessionStatus } = useSession(); const { showMessage } = useSnackbar(); + const { token: authToken } = useWsAuthToken(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [currentIndex, setCurrentIndex] = useState(0); @@ -140,35 +151,36 @@ const HoldClassificationWizard: React.FC = ({ setLoading(true); try { - const response = await fetch( - `/api/internal/hold-classifications?` + - `boardType=${boardDetails.board_name}&` + - `layoutId=${boardDetails.layout_id}&` + - `sizeId=${boardDetails.size_id}` + const data = await executeGraphQL( + GET_HOLD_CLASSIFICATIONS, + { + input: { + boardType: boardDetails.board_name, + layoutId: boardDetails.layout_id, + sizeId: boardDetails.size_id, + }, + }, + authToken, ); - if (response.ok) { - const data = await response.json(); - const classMap = new Map(); - - data.classifications.forEach((c: StoredHoldClassification) => { - classMap.set(c.holdId, { - holdId: c.holdId, - holdType: c.holdType, - handRating: c.handRating, - footRating: c.footRating, - pullDirection: c.pullDirection, - }); + const classMap = new Map(); + data.holdClassifications.forEach((c) => { + classMap.set(c.holdId, { + holdId: c.holdId, + holdType: c.holdType as HoldType | null, + handRating: c.handRating ?? null, + footRating: c.footRating ?? null, + pullDirection: c.pullDirection ?? null, }); + }); - setClassifications(classMap); - } + setClassifications(classMap); } catch (error) { console.error('Failed to load classifications:', error); } finally { setLoading(false); } - }, [boardDetails]); + }, [boardDetails, authToken]); // Load existing classifications when the wizard opens useEffect(() => { @@ -185,31 +197,29 @@ const HoldClassificationWizard: React.FC = ({ const doSaveClassification = useCallback(async (holdId: number, classification: HoldClassification) => { setSaving(true); try { - const response = await fetch('/api/internal/hold-classifications', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - boardType: boardDetails.board_name, - layoutId: boardDetails.layout_id, - sizeId: boardDetails.size_id, - holdId, - holdType: classification.holdType, - handRating: classification.handRating, - footRating: classification.footRating, - pullDirection: classification.pullDirection, - }), - }); - - if (!response.ok) { - throw new Error('Failed to save classification'); - } + await executeGraphQL( + SAVE_HOLD_CLASSIFICATION, + { + input: { + boardType: boardDetails.board_name, + layoutId: boardDetails.layout_id, + sizeId: boardDetails.size_id, + holdId, + holdType: classification.holdType, + handRating: classification.handRating, + footRating: classification.footRating, + pullDirection: classification.pullDirection, + }, + }, + authToken, + ); } catch (error) { console.error('Failed to save classification:', error); showMessage('Failed to save classification', 'error'); } finally { setSaving(false); } - }, [boardDetails]); + }, [boardDetails, authToken]); // Debounced save - waits 500ms after last change before saving const saveClassification = useCallback((holdId: number, classification: HoldClassification) => { diff --git a/packages/web/app/components/party-manager/party-profile-context.tsx b/packages/web/app/components/party-manager/party-profile-context.tsx index de42d37c..e193a86f 100644 --- a/packages/web/app/components/party-manager/party-profile-context.tsx +++ b/packages/web/app/components/party-manager/party-profile-context.tsx @@ -8,6 +8,9 @@ import { clearPartyProfile, ensurePartyProfile, } from '@/app/lib/party-profile-db'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { GET_PROFILE, GetProfileQueryResponse } from '@/app/lib/graphql/operations'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; interface UserProfileData { displayName: string | null; @@ -33,6 +36,7 @@ export const PartyProfileProvider: React.FC<{ children: React.ReactNode }> = ({ const [userProfile, setUserProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const { data: session, status: sessionStatus } = useSession(); + const { token } = useWsAuthToken(); // Load party profile on mount useEffect(() => { @@ -66,18 +70,20 @@ export const PartyProfileProvider: React.FC<{ children: React.ReactNode }> = ({ let mounted = true; const fetchUserProfile = async () => { - if (sessionStatus !== 'authenticated') { + if (sessionStatus !== 'authenticated' || !token) { setUserProfile(null); return; } try { - const response = await fetch('/api/internal/profile'); - if (response.ok) { - const data = await response.json(); - if (mounted) { - setUserProfile(data.profile || null); - } + const data = await executeGraphQL(GET_PROFILE, {}, token); + if (mounted && data.profile) { + setUserProfile({ + displayName: data.profile.displayName, + avatarUrl: data.profile.avatarUrl, + }); + } else if (mounted) { + setUserProfile(null); } } catch (error) { console.error('Failed to fetch user profile:', error); @@ -89,7 +95,7 @@ export const PartyProfileProvider: React.FC<{ children: React.ReactNode }> = ({ return () => { mounted = false; }; - }, [sessionStatus]); + }, [sessionStatus, token]); const refreshProfile = useCallback(async () => { try { @@ -97,18 +103,22 @@ export const PartyProfileProvider: React.FC<{ children: React.ReactNode }> = ({ const loadedProfile = await getPartyProfile(); setProfile(loadedProfile); - // Also refresh user profile from API if authenticated - if (sessionStatus === 'authenticated') { - const response = await fetch('/api/internal/profile'); - if (response.ok) { - const data = await response.json(); - setUserProfile(data.profile || null); + // Also refresh user profile from GraphQL if authenticated + if (sessionStatus === 'authenticated' && token) { + const data = await executeGraphQL(GET_PROFILE, {}, token); + if (data.profile) { + setUserProfile({ + displayName: data.profile.displayName, + avatarUrl: data.profile.avatarUrl, + }); + } else { + setUserProfile(null); } } } catch (error) { console.error('Failed to refresh party profile:', error); } - }, [sessionStatus]); + }, [sessionStatus, token]); const clearProfileHandler = useCallback(async () => { setIsLoading(true); diff --git a/packages/web/app/components/play-view/play-view-beta-slider.tsx b/packages/web/app/components/play-view/play-view-beta-slider.tsx index 04daf12e..41006d16 100644 --- a/packages/web/app/components/play-view/play-view-beta-slider.tsx +++ b/packages/web/app/components/play-view/play-view-beta-slider.tsx @@ -12,7 +12,9 @@ import CloseOutlined from '@mui/icons-material/CloseOutlined'; import PlayArrowOutlined from '@mui/icons-material/PlayArrowOutlined'; import VideocamOutlined from '@mui/icons-material/VideocamOutlined'; import { Instagram, PersonOutlined } from '@mui/icons-material'; -import { BetaLink } from '@/app/lib/api-wrappers/sync-api-types'; +import type { BetaLink } from '@boardsesh/shared-schema'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { GET_BETA_LINKS, type GetBetaLinksQueryResponse, type GetBetaLinksQueryVariables } from '@/app/lib/graphql/operations'; import { themeTokens } from '@/app/theme/theme-config'; const THUMB_SIZE = themeTokens.spacing[16]; // 64px @@ -46,10 +48,11 @@ const PlayViewBetaSlider: React.FC = ({ boardName, clim const fetchBeta = async () => { try { - const res = await fetch(`/api/v1/${boardName}/beta/${climbUuid}`); - if (!res.ok) return; - const data: BetaLink[] = await res.json(); - if (!cancelled) setBetaLinks(data); + const data = await executeGraphQL( + GET_BETA_LINKS, + { boardName, climbUuid }, + ); + if (!cancelled) setBetaLinks(data.betaLinks); } catch (error) { console.error('Failed to fetch beta links:', error); } @@ -125,7 +128,7 @@ const PlayViewBetaSlider: React.FC = ({ boardName, clim = ({ boardName, clim {/* Username chip */} - {link.foreign_username && ( + {link.foreignUsername && ( = ({ boardName, clim lineHeight: 1.4, }} > - @{link.foreign_username} + @{link.foreignUsername} )} @@ -191,10 +194,10 @@ const PlayViewBetaSlider: React.FC = ({ boardName, clim > - {selectedVideo.foreign_username && ( + {selectedVideo.foreignUsername && ( - @{selectedVideo.foreign_username} + @{selectedVideo.foreignUsername} )} {selectedVideo.angle && ( diff --git a/packages/web/app/components/search-drawer/setter-name-select.tsx b/packages/web/app/components/search-drawer/setter-name-select.tsx index fd9ee99f..948e77b6 100644 --- a/packages/web/app/components/search-drawer/setter-name-select.tsx +++ b/packages/web/app/components/search-drawer/setter-name-select.tsx @@ -7,12 +7,9 @@ import CircularProgress from '@mui/material/CircularProgress'; import { useUISearchParams } from '../queue-control/ui-searchparams-provider'; import { useQueueContext } from '../graphql-queue'; import { useQuery, keepPreviousData } from '@tanstack/react-query'; -import { constructSetterStatsUrl } from '@/app/lib/url-utils'; - -interface SetterStat { - setter_username: string; - climb_count: number; -} +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { GET_SETTER_STATS, type GetSetterStatsQueryResponse, type GetSetterStatsQueryVariables } from '@/app/lib/graphql/operations'; +import type { SetterStat } from '@boardsesh/shared-schema'; interface SetterOption { value: string; @@ -20,8 +17,6 @@ interface SetterOption { count: number; } -const fetcher = (url: string) => fetch(url).then((res) => res.json()); - const MIN_SEARCH_LENGTH = 2; // Only search when user has typed at least 2 characters const SetterNameSelect = () => { @@ -34,16 +29,24 @@ const SetterNameSelect = () => { const shouldFetch = isOpen || searchValue.length >= MIN_SEARCH_LENGTH; const isSearching = searchValue.length >= MIN_SEARCH_LENGTH; - // Build API URL - with search query if searching, without if just showing top setters - const apiUrl = shouldFetch - ? constructSetterStatsUrl(parsedParams, isSearching ? searchValue : undefined) - : null; - - // Fetch setter stats from the API + // Fetch setter stats via GraphQL const { data: setterStats, isLoading } = useQuery({ - queryKey: ['setterStats', apiUrl], - queryFn: () => fetcher(apiUrl!), - enabled: !!apiUrl, + queryKey: ['setterStats', parsedParams.board_name, parsedParams.layout_id, parsedParams.size_id, parsedParams.set_ids, parsedParams.angle, isSearching ? searchValue : ''], + queryFn: async () => { + const variables: GetSetterStatsQueryVariables = { + input: { + boardName: parsedParams.board_name, + layoutId: parsedParams.layout_id, + sizeId: parsedParams.size_id, + setIds: Array.isArray(parsedParams.set_ids) ? parsedParams.set_ids.join(',') : String(parsedParams.set_ids), + angle: parsedParams.angle, + search: isSearching ? searchValue : null, + }, + }; + const data = await executeGraphQL(GET_SETTER_STATS, variables); + return data.setterStats; + }, + enabled: shouldFetch, staleTime: 5 * 60 * 1000, placeholderData: keepPreviousData, }); @@ -53,9 +56,9 @@ const SetterNameSelect = () => { if (!setterStats) return []; return setterStats.map(stat => ({ - value: stat.setter_username, - label: `${stat.setter_username} (${stat.climb_count})`, - count: stat.climb_count, + value: stat.setterUsername, + label: `${stat.setterUsername} (${stat.climbCount})`, + count: stat.climbCount, })); }, [setterStats]); diff --git a/packages/web/app/components/search-drawer/use-heatmap.tsx b/packages/web/app/components/search-drawer/use-heatmap.tsx index 645fc1ac..8f5ebc27 100644 --- a/packages/web/app/components/search-drawer/use-heatmap.tsx +++ b/packages/web/app/components/search-drawer/use-heatmap.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, useMemo } from 'react'; import { BoardName, SearchRequestPagination } from '@/app/lib/types'; import { HeatmapData } from '../board-renderer/types'; -import { searchParamsToUrlParams } from '@/app/lib/url-utils'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { GET_HOLD_HEATMAP, type GetHoldHeatmapQueryResponse, type GetHoldHeatmapQueryVariables } from '@/app/lib/graphql/operations'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; interface UseHeatmapDataProps { boardName: BoardName; @@ -25,6 +27,7 @@ export default function useHeatmapData({ const [heatmapData, setHeatmapData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { token: authToken } = useWsAuthToken(); // Serialize filters to create a stable dependency - prevents re-fetching // when object reference changes but contents are the same @@ -42,22 +45,54 @@ export default function useHeatmapData({ const fetchHeatmapData = async () => { try { - // Server uses NextAuth session for user-specific data - const response = await fetch( - `/api/v1/${boardName}/${layoutId}/${sizeId}/${setIds}/${angle}/heatmap?${searchParamsToUrlParams(filters).toString()}`, - ); + // Build holdsFilter as Record for GraphQL + const holdsFilter: Record | undefined = filters.holdsFilter + ? Object.fromEntries( + Object.entries(filters.holdsFilter) + .filter(([, v]) => v && typeof v === 'object' && 'state' in v) + .map(([k, v]) => [k, (v as { state: string }).state]) + ) + : undefined; - if (cancelled) return; + const variables: GetHoldHeatmapQueryVariables = { + input: { + boardName, + layoutId, + sizeId, + setIds, + angle, + gradeAccuracy: filters.gradeAccuracy !== undefined ? String(filters.gradeAccuracy) : null, + minGrade: filters.minGrade ?? null, + maxGrade: filters.maxGrade ?? null, + minAscents: filters.minAscents ?? null, + minRating: filters.minRating ?? null, + sortBy: filters.sortBy ?? null, + sortOrder: filters.sortOrder ?? null, + name: filters.name || null, + settername: filters.settername && filters.settername.length > 0 ? filters.settername : null, + onlyClassics: filters.onlyClassics || null, + onlyTallClimbs: filters.onlyTallClimbs || null, + holdsFilter: holdsFilter && Object.keys(holdsFilter).length > 0 ? holdsFilter : null, + hideAttempted: filters.hideAttempted || null, + hideCompleted: filters.hideCompleted || null, + showOnlyAttempted: filters.showOnlyAttempted || null, + showOnlyCompleted: filters.showOnlyCompleted || null, + }, + }; - if (!response.ok) { - throw new Error('Failed to fetch heatmap data'); - } - - const data = await response.json(); + const data = await executeGraphQL( + GET_HOLD_HEATMAP, + variables, + authToken ?? undefined, + ); if (cancelled) return; - setHeatmapData(data.holdStats); + setHeatmapData(data.holdHeatmap.map((stat) => ({ + ...stat, + userAscents: stat.userAscents ?? undefined, + userAttempts: stat.userAttempts ?? undefined, + }))); setError(null); } catch (err) { if (cancelled) return; @@ -76,7 +111,7 @@ export default function useHeatmapData({ cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- filtersKey is a serialized version of filters - }, [boardName, layoutId, sizeId, setIds, angle, filtersKey, enabled]); + }, [boardName, layoutId, sizeId, setIds, angle, filtersKey, enabled, authToken]); return { data: heatmapData, loading, error }; } diff --git a/packages/web/app/components/settings/aurora-credentials-section.tsx b/packages/web/app/components/settings/aurora-credentials-section.tsx index 73c3818e..92068539 100644 --- a/packages/web/app/components/settings/aurora-credentials-section.tsx +++ b/packages/web/app/components/settings/aurora-credentials-section.tsx @@ -25,8 +25,15 @@ import DeleteOutlined from '@mui/icons-material/DeleteOutlined'; import AddOutlined from '@mui/icons-material/AddOutlined'; import SyncOutlined from '@mui/icons-material/SyncOutlined'; import WarningOutlined from '@mui/icons-material/WarningOutlined'; -import type { AuroraCredentialStatus } from '@/app/api/internal/aurora-credentials/route'; -import type { UnsyncedCounts } from '@/app/api/internal/aurora-credentials/unsynced/route'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { + GET_AURORA_CREDENTIALS, + GET_UNSYNCED_COUNTS, + type GetAuroraCredentialsQueryResponse, + type GetUnsyncedCountsQueryResponse, + type AuroraCredentialStatusGql, +} from '@/app/lib/graphql/operations'; import styles from './aurora-credentials-section.module.css'; interface BoardUnsyncedCounts { @@ -34,9 +41,14 @@ interface BoardUnsyncedCounts { climbs: number; } +interface UnsyncedCounts { + kilter: BoardUnsyncedCounts; + tension: BoardUnsyncedCounts; +} + interface BoardCredentialCardProps { boardType: 'kilter' | 'tension'; - credential: AuroraCredentialStatus | null; + credential: AuroraCredentialStatusGql | null; unsyncedCounts: BoardUnsyncedCounts; onAdd: () => void; onRemove: () => void; @@ -115,11 +127,11 @@ function BoardCredentialCard({
Username: - {credential.auroraUsername} + {credential.username}
Last synced: - {formatLastSync(credential.lastSyncAt)} + {formatLastSync(credential.syncedAt)}
{credential.syncError && (
@@ -161,7 +173,8 @@ function BoardCredentialCard({ export default function AuroraCredentialsSection() { const { showMessage } = useSnackbar(); - const [credentials, setCredentials] = useState([]); + const { token: authToken } = useWsAuthToken(); + const [credentials, setCredentials] = useState([]); const [unsyncedCounts, setUnsyncedCounts] = useState(null); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); @@ -171,12 +184,14 @@ export default function AuroraCredentialsSection() { const [formValues, setFormValues] = useState({ username: '', password: '' }); const fetchCredentials = async () => { + if (!authToken) return; try { - const response = await fetch('/api/internal/aurora-credentials'); - if (response.ok) { - const data = await response.json(); - setCredentials(data.credentials); - } + const data = await executeGraphQL( + GET_AURORA_CREDENTIALS, + {}, + authToken, + ); + setCredentials(data.auroraCredentials); } catch (error) { console.error('Failed to fetch credentials:', error); } finally { @@ -186,20 +201,23 @@ export default function AuroraCredentialsSection() { const fetchUnsyncedCounts = async () => { try { - const response = await fetch('/api/internal/aurora-credentials/unsynced'); - if (response.ok) { - const data = await response.json(); - setUnsyncedCounts(data.counts); - } + const data = await executeGraphQL( + GET_UNSYNCED_COUNTS, + {}, + authToken, + ); + setUnsyncedCounts(data.unsyncedCounts); } catch (error) { console.error('Failed to fetch unsynced counts:', error); } }; useEffect(() => { - fetchCredentials(); + if (authToken) { + fetchCredentials(); + } fetchUnsyncedCounts(); - }, []); + }, [authToken]); const handleAddClick = (boardType: 'kilter' | 'tension') => { setSelectedBoard(boardType); diff --git a/packages/web/app/components/settings/controllers-section.tsx b/packages/web/app/components/settings/controllers-section.tsx index 65028794..694b7406 100644 --- a/packages/web/app/components/settings/controllers-section.tsx +++ b/packages/web/app/components/settings/controllers-section.tsx @@ -27,17 +27,29 @@ import DeleteOutlined from '@mui/icons-material/DeleteOutlined'; import AddOutlined from '@mui/icons-material/AddOutlined'; import ContentCopyOutlined from '@mui/icons-material/ContentCopyOutlined'; import WarningOutlined from '@mui/icons-material/WarningOutlined'; -import type { ControllerInfo } from '@/app/api/internal/controllers/route'; import { getBoardSelectorOptions } from '@/app/lib/__generated__/product-sizes-data'; import { BoardName } from '@/app/lib/types'; import styles from './controllers-section.module.css'; import { useSnackbar } from '@/app/components/providers/snackbar-provider'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { + GET_MY_CONTROLLERS, + REGISTER_CONTROLLER, + DELETE_CONTROLLER, + type GetMyControllersQueryResponse, + type ControllerInfoGql, + type RegisterControllerMutationVariables, + type RegisterControllerMutationResponse, + type DeleteControllerMutationVariables, + type DeleteControllerMutationResponse, +} from '@/app/lib/graphql/operations'; // Get board config data (synchronous - from generated data) const boardSelectorOptions = getBoardSelectorOptions(); interface ControllerCardProps { - controller: ControllerInfo; + controller: ControllerInfoGql; onRemove: () => void; isRemoving: boolean; } @@ -184,8 +196,9 @@ function ApiKeySuccessModal({ isOpen, apiKey, controllerName, onClose }: ApiKeyS } export default function ControllersSection() { - const [controllers, setControllers] = useState([]); + const [controllers, setControllers] = useState([]); const [loading, setLoading] = useState(true); + const { token: authToken } = useWsAuthToken(); const [isModalOpen, setIsModalOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const [removingId, setRemovingId] = useState(null); @@ -223,12 +236,14 @@ export default function ControllersSection() { const [successControllerName, setSuccessControllerName] = useState(''); const fetchControllers = async () => { + if (!authToken) return; try { - const response = await fetch('/api/internal/controllers'); - if (response.ok) { - const data = await response.json(); - setControllers(data.controllers); - } + const data = await executeGraphQL( + GET_MY_CONTROLLERS, + {}, + authToken, + ); + setControllers(data.myControllers); } catch (error) { console.error('Failed to fetch controllers:', error); } finally { @@ -237,8 +252,10 @@ export default function ControllersSection() { }; useEffect(() => { - fetchControllers(); - }, []); + if (authToken) { + fetchControllers(); + } + }, [authToken]); const handleAddClick = () => { setFormValues({ name: '' }); @@ -294,31 +311,26 @@ export default function ControllersSection() { }) => { setIsSaving(true); try { - const response = await fetch('/api/internal/controllers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: values.name, - boardName: values.boardName, - layoutId: values.layoutId, - sizeId: values.sizeId, - setIds: Array.isArray(values.setIds) ? values.setIds.join(',') : values.setIds, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to register controller'); - } - - const data = await response.json(); + const data = await executeGraphQL( + REGISTER_CONTROLLER, + { + input: { + name: values.name, + boardName: values.boardName, + layoutId: values.layoutId, + sizeId: values.sizeId, + setIds: Array.isArray(values.setIds) ? values.setIds.join(',') : String(values.setIds), + }, + }, + authToken, + ); // Close the registration modal setIsModalOpen(false); setFormValues({ name: '' }); // Show the API key success modal - setSuccessApiKey(data.apiKey); + setSuccessApiKey(data.registerController.apiKey); setSuccessControllerName(values.name || ''); await fetchControllers(); @@ -332,16 +344,11 @@ export default function ControllersSection() { const handleRemove = async (controllerId: string) => { setRemovingId(controllerId); try { - const response = await fetch('/api/internal/controllers', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ controllerId }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete controller'); - } + await executeGraphQL( + DELETE_CONTROLLER, + { controllerId }, + authToken, + ); showMessage('Controller deleted successfully', 'success'); await fetchControllers(); diff --git a/packages/web/app/crusher/[user_id]/profile-page-content.tsx b/packages/web/app/crusher/[user_id]/profile-page-content.tsx index d49541f8..e20abcd9 100644 --- a/packages/web/app/crusher/[user_id]/profile-page-content.tsx +++ b/packages/web/app/crusher/[user_id]/profile-page-content.tsx @@ -27,7 +27,8 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import styles from './profile-page.module.css'; import type { ChartData } from './profile-stats-charts'; -import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; +import { createGraphQLHttpClient, executeGraphQL } from '@/app/lib/graphql/client'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; import { GET_USER_TICKS, type GetUserTicksQueryVariables, @@ -37,6 +38,9 @@ import { type GetUserProfileStatsQueryResponse, FOLLOW_USER, UNFOLLOW_USER, + GET_PUBLIC_PROFILE, + type GetPublicProfileQueryVariables, + type GetPublicProfileQueryResponse, } from '@/app/lib/graphql/operations'; import { FONT_GRADE_COLORS, getGradeColorWithOpacity } from '@/app/lib/grade-colors'; import { SUPPORTED_BOARDS } from '@/app/lib/board-data'; @@ -59,14 +63,9 @@ const ProfileStatsCharts = dynamic(() => import('./profile-stats-charts'), { interface UserProfile { id: string; - email: string; - name: string | null; - image: string | null; - profile: { - displayName: string | null; - avatarUrl: string | null; - instagramUrl: string | null; - } | null; + displayName: string | null; + avatarUrl: string | null; + instagramUrl: string | null; followerCount: number; followingCount: number; isFollowedByMe: boolean; @@ -209,31 +208,31 @@ export default function ProfilePageContent({ userId }: { userId: string }) { const isOwnProfile = session?.user?.id === userId; const { showMessage } = useSnackbar(); + const { token: authToken } = useWsAuthToken(); // Fetch profile data for the userId in the URL const fetchProfile = useCallback(async () => { try { - const response = await fetch(`/api/internal/profile/${userId}`); + // Use auth token if available (for isFollowedByMe), but publicProfile works without auth too + const data = await executeGraphQL( + GET_PUBLIC_PROFILE, + { userId }, + authToken, + ); - if (response.status === 404) { + if (!data.publicProfile) { setNotFound(true); return; } - if (!response.ok) { - throw new Error('Failed to fetch profile'); - } - - const data = await response.json(); setProfile({ - id: data.id, - email: data.email, - name: data.name, - image: data.image, - profile: data.profile, - followerCount: data.followerCount ?? 0, - followingCount: data.followingCount ?? 0, - isFollowedByMe: data.isFollowedByMe ?? false, + id: data.publicProfile.id, + displayName: data.publicProfile.displayName ?? null, + avatarUrl: data.publicProfile.avatarUrl ?? null, + instagramUrl: data.publicProfile.instagramUrl ?? null, + followerCount: data.publicProfile.followerCount, + followingCount: data.publicProfile.followingCount, + isFollowedByMe: data.publicProfile.isFollowedByMe, }); } catch (error) { console.error('Failed to fetch profile:', error); @@ -241,7 +240,7 @@ export default function ProfilePageContent({ userId }: { userId: string }) { } finally { setLoading(false); } - }, [userId]); + }, [userId, authToken]); // Fetch ticks from GraphQL backend const fetchLogbook = useCallback(async (boardType: string) => { @@ -647,9 +646,9 @@ export default function ProfilePageContent({ userId }: { userId: string }) { ); } - const displayName = profile?.profile?.displayName || profile?.name || 'Crusher'; - const avatarUrl = profile?.profile?.avatarUrl || profile?.image; - const instagramUrl = profile?.profile?.instagramUrl; + const displayName = profile?.displayName || 'Crusher'; + const avatarUrl = profile?.avatarUrl; + const instagramUrl = profile?.instagramUrl; // Board options are now available for all users (no Aurora credentials required) const boardOptions = BOARD_TYPES.map((boardType) => ({ @@ -720,8 +719,8 @@ export default function ProfilePageContent({ userId }: { userId: string }) { followerCount={profile?.followerCount ?? 0} followingCount={profile?.followingCount ?? 0} /> - {isOwnProfile && ( - {profile?.email} + {isOwnProfile && session?.user?.email && ( + {session.user.email} )} {instagramUrl && ( >, board: AuroraBoardName, data: Attempt[]) => - Promise.all( - data.map(async (item) => { - const attemptsSchema = UNIFIED_TABLES.attempts; - return db - .insert(attemptsSchema) - .values({ - boardType: board, - id: Number(item.id), - position: Number(item.position), - name: item.name, - }) - .onConflictDoUpdate({ - target: [attemptsSchema.boardType, attemptsSchema.id], - set: { - // Only allow position updates if they're reasonable (0-100) - position: sql`CASE WHEN ${Number(item.position)} >= 0 AND ${Number(item.position)} <= 100 THEN ${Number(item.position)} ELSE ${attemptsSchema.position} END`, - // Allow name updates for display purposes - name: item.name, - }, - }); - }), - ); - -async function upsertClimbStats(db: NeonDatabase>, board: AuroraBoardName, data: ClimbStats[]) { - const climbStatsSchema = UNIFIED_TABLES.climbStats; - const climbStatHistorySchema = UNIFIED_TABLES.climbStatsHistory; - - await Promise.all( - data.map((item) => { - return Promise.all([ - // Update current stats - db - .insert(climbStatsSchema) - .values({ - boardType: board, - climbUuid: item.climb_uuid, - angle: Number(item.angle), - displayDifficulty: Number(item.display_difficulty || item.difficulty_average), - benchmarkDifficulty: item.benchmark_difficulty != null ? Number(item.benchmark_difficulty) : null, - ascensionistCount: Number(item.ascensionist_count), - difficultyAverage: Number(item.difficulty_average), - qualityAverage: Number(item.quality_average), - faUsername: item.fa_username, - faAt: item.fa_at, - }) - .onConflictDoUpdate({ - target: [climbStatsSchema.boardType, climbStatsSchema.climbUuid, climbStatsSchema.angle], - set: { - displayDifficulty: Number(item.display_difficulty || item.difficulty_average), - benchmarkDifficulty: item.benchmark_difficulty != null ? Number(item.benchmark_difficulty) : null, - ascensionistCount: Number(item.ascensionist_count), - difficultyAverage: Number(item.difficulty_average), - qualityAverage: Number(item.quality_average), - faUsername: item.fa_username, - faAt: item.fa_at, - }, - }), - - // Also insert into history table - db.insert(climbStatHistorySchema).values({ - boardType: board, - climbUuid: item.climb_uuid, - angle: Number(item.angle), - displayDifficulty: Number(item.display_difficulty || item.difficulty_average), - benchmarkDifficulty: item.benchmark_difficulty != null ? Number(item.benchmark_difficulty) : null, - ascensionistCount: Number(item.ascensionist_count), - difficultyAverage: Number(item.difficulty_average), - qualityAverage: Number(item.quality_average), - faUsername: item.fa_username, - faAt: item.fa_at, - }), - ]); - }), - ); -} - -async function upsertBetaLinks(db: NeonDatabase>, board: AuroraBoardName, data: BetaLink[]) { - const betaLinksSchema = UNIFIED_TABLES.betaLinks; - - await Promise.all( - data.map((item) => { - return db - .insert(betaLinksSchema) - .values({ - boardType: board, - climbUuid: item.climb_uuid, - link: item.link, - foreignUsername: item.foreign_username, - angle: item.angle, - thumbnail: item.thumbnail, - isListed: item.is_listed, - createdAt: item.created_at, - }) - .onConflictDoUpdate({ - target: [betaLinksSchema.boardType, betaLinksSchema.climbUuid, betaLinksSchema.link], - set: { - foreignUsername: item.foreign_username, - angle: item.angle, - thumbnail: item.thumbnail, - isListed: item.is_listed, - createdAt: item.created_at, - }, - }); - }), - ); -} - -async function upsertClimbs(db: NeonDatabase>, board: AuroraBoardName, data: Climb[]) { - const climbsSchema = UNIFIED_TABLES.climbs; - const climbHoldsSchema = UNIFIED_TABLES.climbHolds; - - await Promise.all( - data.map(async (item: Climb) => { - // Insert or update the climb - await db - .insert(climbsSchema) - .values({ - uuid: item.uuid, - boardType: board, - name: item.name, - description: item.description, - hsm: item.hsm, - edgeLeft: item.edge_left, - edgeRight: item.edge_right, - edgeBottom: item.edge_bottom, - edgeTop: item.edge_top, - framesCount: item.frames_count, - framesPace: item.frames_pace, - frames: item.frames, - setterId: item.setter_id, - setterUsername: item.setter_username, - layoutId: item.layout_id, - isDraft: item.is_draft, - isListed: item.is_listed, - createdAt: item.created_at, - angle: item.angle, - }) - .onConflictDoUpdate({ - target: [climbsSchema.uuid], - set: { - // Only allow isDraft to change from false to true (publishing) - isDraft: sql`CASE WHEN ${climbsSchema.isDraft} = false AND ${item.is_draft} = true THEN true ELSE ${climbsSchema.isDraft} END`, - // Only allow isListed to change from false to true (making public) - isListed: sql`CASE WHEN ${climbsSchema.isListed} = false AND ${item.is_listed} = true THEN true ELSE ${climbsSchema.isListed} END`, - // Allow updates to descriptive fields - name: item.name, - description: item.description, - // Preserve all core climb data - never allow hostile updates to these critical fields - hsm: climbsSchema.hsm, - edgeLeft: climbsSchema.edgeLeft, - edgeRight: climbsSchema.edgeRight, - edgeBottom: climbsSchema.edgeBottom, - edgeTop: climbsSchema.edgeTop, - framesCount: climbsSchema.framesCount, - framesPace: climbsSchema.framesPace, - frames: climbsSchema.frames, - setterId: climbsSchema.setterId, - setterUsername: climbsSchema.setterUsername, - layoutId: climbsSchema.layoutId, - angle: climbsSchema.angle, - }, - }); - - const holdsByFrame = convertLitUpHoldsStringToMap(item.frames, board); - - const holdsToInsert = Object.entries(holdsByFrame).flatMap(([frameNumber, holds]) => - Object.entries(holds).map(([holdId, { state }]) => ({ - boardType: board, - climbUuid: item.uuid, - frameNumber: Number(frameNumber), - holdId: Number(holdId), - holdState: state, - })), - ); - - await db.insert(climbHoldsSchema).values(holdsToInsert).onConflictDoNothing(); // Avoid duplicate inserts - }), - ); -} - -async function upsertSharedTableData( - db: NeonDatabase>, - boardName: AuroraBoardName, - tableName: string, - data: SyncPutFields[], -) { - switch (tableName) { - case 'attempts': - await upsertAttempts(db, boardName, data as Attempt[]); - break; - case 'climb_stats': - await upsertClimbStats(db, boardName, data as ClimbStats[]); - break; - case 'beta_links': - await upsertBetaLinks(db, boardName, data as BetaLink[]); - break; - case 'climbs': - await upsertClimbs(db, boardName, data as Climb[]); - break; - case 'shared_syncs': - await updateSharedSyncs(db, boardName, data as SharedSync[]); - break; - default: - // Tables not in TABLES_TO_PROCESS are handled in the main sync loop - console.log(`Table ${tableName} not handled in upsertSharedTableData`); - break; - } -} -async function updateSharedSyncs( - tx: NeonDatabase>, - boardName: AuroraBoardName, - sharedSyncs: SharedSync[], -) { - const sharedSyncsSchema = UNIFIED_TABLES.sharedSyncs; - - for (const sync of sharedSyncs) { - await tx - .insert(sharedSyncsSchema) - .values({ - boardType: boardName, - tableName: sync.table_name, - lastSynchronizedAt: sync.last_synchronized_at, - }) - .onConflictDoUpdate({ - target: [sharedSyncsSchema.boardType, sharedSyncsSchema.tableName], - set: { - lastSynchronizedAt: sync.last_synchronized_at, - }, - }); - } -} - -export async function getLastSharedSyncTimes(boardName: AuroraBoardName) { - const sharedSyncsSchema = UNIFIED_TABLES.sharedSyncs; - const pool = getPool(); - const client = await pool.connect(); - - try { - const db = drizzle(client); - const result = await db - .select({ - table_name: sharedSyncsSchema.tableName, - last_synchronized_at: sharedSyncsSchema.lastSynchronizedAt, - }) - .from(sharedSyncsSchema) - .where(eq(sharedSyncsSchema.boardType, boardName)); - - return result; - } finally { - client.release(); - } -} - -export async function syncSharedData( - board: AuroraBoardName, - token: string, -): Promise<{ complete: boolean; results: Record }> { - try { - console.log('Entered sync shared data'); - - // Get shared sync times - const allSyncTimes = await getLastSharedSyncTimes(board); - console.log('Fetched previous sync times:', allSyncTimes); - - // Create a map of existing sync times - const sharedSyncMap = new Map(allSyncTimes.map((sync) => [sync.table_name, sync.last_synchronized_at])); - - // Ensure all shared tables have a sync entry (default to 1970 if not synced) - const defaultTimestamp = '1970-01-01 00:00:00.000000'; - - const syncParams: SyncOptions = { - tables: [...SHARED_SYNC_TABLES], - sharedSyncs: SHARED_SYNC_TABLES.map((tableName) => ({ - table_name: tableName, - last_synchronized_at: sharedSyncMap.get(tableName) || defaultTimestamp, - })), - }; - - console.log('syncParams', syncParams); - - // Initialize results tracking - const totalResults: Record = {}; - let isComplete = false; - - const syncResults = await sharedSync(board, syncParams, token); - console.log('syncResults keys:', Object.keys(syncResults)); - console.log('syncResults structure:', JSON.stringify(syncResults, null, 2).substring(0, 1000)); - - // Process this batch in a transaction - const pool = getPool(); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // Create a drizzle instance for this transaction - const tx = drizzle(client); - - // Process each table - data is directly under table names - for (const tableName of SHARED_SYNC_TABLES) { - if (syncResults[tableName] && Array.isArray(syncResults[tableName])) { - const data = syncResults[tableName]; - - // Only process tables we actually care about - if (TABLES_TO_PROCESS.has(tableName)) { - console.log(`Syncing ${tableName}: ${data.length} records`); - await upsertSharedTableData(tx, board, tableName, data); - - // Accumulate results - if (!totalResults[tableName]) { - totalResults[tableName] = { synced: 0, complete: false }; - } - totalResults[tableName].synced += data.length; - } else { - console.log(`Skipping ${tableName}: ${data.length} records (not processed)`); - // Still track in results but don't sync - if (!totalResults[tableName]) { - totalResults[tableName] = { synced: 0, complete: false }; - } - } - } else if (!totalResults[tableName]) { - totalResults[tableName] = { synced: 0, complete: false }; - } - } - - // Update shared_syncs table with new sync times from this batch - if (syncResults['shared_syncs']) { - console.log('Updating shared_syncs with data:', syncResults['shared_syncs']); - await updateSharedSyncs(tx, board, syncResults['shared_syncs']); - - // Update sync params for next iteration with new timestamps - const newSharedSyncs = syncResults['shared_syncs'].map( - (sync: { table_name: string; last_synchronized_at: string }) => ({ - table_name: sync.table_name, - last_synchronized_at: sync.last_synchronized_at, - }), - ); - - // Log timestamp updates for debugging - const climbsSync = newSharedSyncs.find((s: { table_name: string }) => s.table_name === 'climbs'); - if (climbsSync) { - console.log(`Climbs table sync timestamp updated to: ${climbsSync.last_synchronized_at}`); - } - - // Update syncParams for next batch - syncParams.sharedSyncs = newSharedSyncs; - } else { - console.log('No shared_syncs data in sync results'); - } - - await client.query('COMMIT'); - } catch (error) { - await client.query('ROLLBACK'); - console.error('Failed to commit sync database transaction:', error); - throw error; - } finally { - client.release(); - } - - // Check if sync is complete - default to true if _complete is not present (matches Android app behavior) - isComplete = syncResults._complete !== false; - - console.log(`Sync complete. _complete flag: ${syncResults._complete}, isComplete: ${isComplete}`); - - // Mark completion status for all tables - Object.keys(totalResults).forEach((table) => { - totalResults[table].complete = isComplete; - }); - - // Log summary of what was synced - console.log('Sync batch summary:'); - Object.entries(totalResults).forEach(([table, result]) => { - if (result.synced > 0) { - console.log(` ${table}: ${result.synced} records synced`); - } - }); - console.log(`Sync complete: ${isComplete}`); - - return { complete: isComplete, results: totalResults }; - } catch (error) { - console.error('Error syncing shared data:', error); - throw error; - } -} diff --git a/packages/web/app/lib/data-sync/aurora/user-sync.ts b/packages/web/app/lib/data-sync/aurora/user-sync.ts index 44eefef5..77aa439e 100644 --- a/packages/web/app/lib/data-sync/aurora/user-sync.ts +++ b/packages/web/app/lib/data-sync/aurora/user-sync.ts @@ -7,7 +7,14 @@ import { NeonDatabase } from 'drizzle-orm/neon-serverless'; import { UNIFIED_TABLES } from '../../db/queries/util/table-select'; import { boardseshTicks, auroraCredentials, playlists, playlistClimbs, playlistOwnership } from '../../db/schema'; import { randomUUID } from 'crypto'; -import { convertQuality } from './convert-quality'; + +/** + * Convert Aurora quality (1-3 scale) to Boardsesh quality (1-5 scale) + */ +function convertQuality(auroraQuality: number | null | undefined): number | null { + if (auroraQuality == null) return null; + return Math.round((auroraQuality / 3.0) * 5); +} /** * Get NextAuth user ID from Aurora user ID diff --git a/packages/web/app/lib/db/queries/climbs/Untitled b/packages/web/app/lib/db/queries/climbs/Untitled deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/web/app/lib/db/queries/climbs/create-climb-filters.ts b/packages/web/app/lib/db/queries/climbs/create-climb-filters.ts deleted file mode 100644 index f8b9fa8b..00000000 --- a/packages/web/app/lib/db/queries/climbs/create-climb-filters.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { eq, gte, sql, like, notLike, inArray, SQL } from 'drizzle-orm'; -import { ParsedBoardRouteParameters, SearchRequestPagination } from '@/app/lib/types'; -import { UNIFIED_TABLES } from '@/lib/db/queries/util/table-select'; -import { SizeEdges } from '@/app/lib/__generated__/product-sizes-data'; -import { SUPPORTED_BOARDS } from '@/app/lib/board-data'; -import { KILTER_HOMEWALL_LAYOUT_ID, KILTER_HOMEWALL_PRODUCT_ID } from '@/app/lib/board-constants'; -import { boardseshTicks } from '@/app/lib/db/schema'; - -// Type for unified tables used by filters -type UnifiedTables = typeof UNIFIED_TABLES; - -/** - * Creates a shared filtering object that can be used by both search climbs and heatmap queries - * Uses unified tables (board_climbs, board_climb_stats, etc.) with board_type filtering - * @param params The route parameters (includes board_name for filtering) - * @param searchParams The search parameters - * @param sizeEdges Pre-fetched edge values from product_sizes table - * @param userId Optional NextAuth user ID to include user-specific ascent and attempt data - */ -export const createClimbFilters = ( - params: ParsedBoardRouteParameters, - searchParams: SearchRequestPagination, - sizeEdges: SizeEdges, - userId?: string, -) => { - const tables = UNIFIED_TABLES; - // Defense in depth: validate board_name before using in SQL queries - if (!SUPPORTED_BOARDS.includes(params.board_name)) { - throw new Error(`Invalid board name: ${params.board_name}`); - } - // Process hold filters - // holdsFilter can have values like: - // - 'ANY': hold must be present in the climb - // - 'NOT': hold must NOT be present in the climb - // - { state: 'STARTING' | 'HAND' | 'FOOT' | 'FINISH' }: hold must be present with that specific state - // - 'STARTING' | 'HAND' | 'FOOT' | 'FINISH': (after URL parsing) same as above - const holdsToFilter = Object.entries(searchParams.holdsFilter || {}).map(([key, stateOrValue]) => { - const holdId = key.replace('hold_', ''); - // Handle both object form { state: 'STARTING' } and string form 'STARTING' (after URL parsing) - const state = typeof stateOrValue === 'object' && stateOrValue !== null - ? (stateOrValue as { state: string }).state - : stateOrValue; - return [holdId, state] as const; - }); - - const anyHolds = holdsToFilter.filter(([, value]) => value === 'ANY').map(([key]) => Number(key)); - const notHolds = holdsToFilter.filter(([, value]) => value === 'NOT').map(([key]) => Number(key)); - - // Hold state filters - hold must be present with specific state (STARTING, HAND, FOOT, FINISH) - const holdStateFilters = holdsToFilter - .filter(([, value]) => ['STARTING', 'HAND', 'FOOT', 'FINISH'].includes(value as string)) - .map(([key, state]) => ({ holdId: Number(key), state: state as string })); - - // Base conditions for filtering climbs - includes board_type filter for unified tables - const baseConditions: SQL[] = [ - eq(tables.climbs.boardType, params.board_name), - eq(tables.climbs.layoutId, params.layout_id), - eq(tables.climbs.isListed, true), - eq(tables.climbs.isDraft, false), - eq(tables.climbs.framesCount, 1), - ]; - - // Size-specific conditions using pre-fetched static edge values - // This eliminates the need for a JOIN on product_sizes in the main query - // MoonBoard climbs have NULL edge values (single fixed size), so skip edge filtering - const sizeConditions: SQL[] = params.board_name === 'moonboard' ? [] : [ - sql`${tables.climbs.edgeLeft} > ${sizeEdges.edgeLeft}`, - sql`${tables.climbs.edgeRight} < ${sizeEdges.edgeRight}`, - sql`${tables.climbs.edgeBottom} > ${sizeEdges.edgeBottom}`, - sql`${tables.climbs.edgeTop} < ${sizeEdges.edgeTop}`, - ]; - - // Conditions for climb stats - const climbStatsConditions: SQL[] = []; - - if (searchParams.minAscents) { - climbStatsConditions.push(gte(tables.climbStats.ascensionistCount, searchParams.minAscents)); - } - - if (searchParams.minGrade && searchParams.maxGrade) { - climbStatsConditions.push( - sql`ROUND(${tables.climbStats.displayDifficulty}::numeric, 0) BETWEEN ${searchParams.minGrade} AND ${searchParams.maxGrade}`, - ); - } else if (searchParams.minGrade) { - climbStatsConditions.push( - sql`ROUND(${tables.climbStats.displayDifficulty}::numeric, 0) >= ${searchParams.minGrade}`, - ); - } else if (searchParams.maxGrade) { - climbStatsConditions.push( - sql`ROUND(${tables.climbStats.displayDifficulty}::numeric, 0) <= ${searchParams.maxGrade}`, - ); - } - - if (searchParams.minRating) { - climbStatsConditions.push(sql`${tables.climbStats.qualityAverage} >= ${searchParams.minRating}`); - } - - if (searchParams.gradeAccuracy) { - climbStatsConditions.push( - sql`ABS(ROUND(${tables.climbStats.displayDifficulty}::numeric, 0) - ${tables.climbStats.difficultyAverage}::numeric) <= ${searchParams.gradeAccuracy}`, - ); - } - - // Name search condition (only used in searchClimbs) - const nameCondition: SQL[] = searchParams.name ? [sql`${tables.climbs.name} ILIKE ${`%${searchParams.name}%`}`] : []; - - // Setter name filter condition - const setterNameCondition: SQL[] = searchParams.settername && searchParams.settername.length > 0 - ? [inArray(tables.climbs.setterUsername, searchParams.settername)] - : []; - - // Hold filter conditions - const holdConditions: SQL[] = [ - ...anyHolds.map((holdId) => like(tables.climbs.frames, `%${holdId}r%`)), - ...notHolds.map((holdId) => notLike(tables.climbs.frames, `%${holdId}r%`)), - ]; - - // State-specific hold conditions - use unified board_climb_holds table to filter by hold_id AND hold_state - const holdStateConditions: SQL[] = holdStateFilters.map(({ holdId, state }) => - sql`EXISTS ( - SELECT 1 FROM board_climb_holds ch - WHERE ch.board_type = ${params.board_name} - AND ch.climb_uuid = ${tables.climbs.uuid} - AND ch.hold_id = ${holdId} - AND ch.hold_state = ${state} - )` - ); - - // Tall climbs filter condition - // Only applies for Kilter Homewall (layout_id = 8) on the largest size - // A "tall climb" is one that uses holds in the bottom rows that are only available on the largest size - const tallClimbsConditions: SQL[] = []; - - if (searchParams.onlyTallClimbs && params.board_name === 'kilter' && params.layout_id === KILTER_HOMEWALL_LAYOUT_ID) { - // Find the maximum edge_bottom of all sizes smaller than the current size - // Climbs with edge_bottom below this threshold use "tall only" holds - // For Kilter Homewall (productId=7), 7x10/10x10 sizes have edgeBottom=24, 8x12/10x12 have edgeBottom=-12 - // So "tall climbs" are those with edgeBottom < 24 (using holds only available on 12-tall sizes) - tallClimbsConditions.push( - sql`${tables.climbs.edgeBottom} < ( - SELECT MAX(ps.edge_bottom) - FROM board_product_sizes ps - WHERE ps.board_type = ${params.board_name} - AND ps.product_id = ${KILTER_HOMEWALL_PRODUCT_ID} - AND ps.id != ${params.size_id} - )` - ); - } - - // Personal progress filter conditions (only apply if userId is provided) - // Uses boardsesh_ticks with NextAuth userId - const personalProgressConditions: SQL[] = []; - if (userId) { - if (searchParams.hideAttempted) { - personalProgressConditions.push( - sql`NOT EXISTS ( - SELECT 1 FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${tables.climbs.uuid} - AND ${boardseshTicks.userId} = ${userId} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} = 'attempt' - )` - ); - } - - if (searchParams.hideCompleted) { - personalProgressConditions.push( - sql`NOT EXISTS ( - SELECT 1 FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${tables.climbs.uuid} - AND ${boardseshTicks.userId} = ${userId} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} IN ('flash', 'send') - )` - ); - } - - if (searchParams.showOnlyAttempted) { - personalProgressConditions.push( - sql`EXISTS ( - SELECT 1 FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${tables.climbs.uuid} - AND ${boardseshTicks.userId} = ${userId} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} = 'attempt' - )` - ); - } - - if (searchParams.showOnlyCompleted) { - personalProgressConditions.push( - sql`EXISTS ( - SELECT 1 FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${tables.climbs.uuid} - AND ${boardseshTicks.userId} = ${userId} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} IN ('flash', 'send') - )` - ); - } - } - - // User-specific logbook data selectors using boardsesh_ticks - const getUserLogbookSelects = () => { - return { - userAscents: sql`( - SELECT COUNT(*) - FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${tables.climbs.uuid} - AND ${boardseshTicks.userId} = ${userId || ''} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} IN ('flash', 'send') - )`, - userAttempts: sql`( - SELECT COUNT(*) - FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${tables.climbs.uuid} - AND ${boardseshTicks.userId} = ${userId || ''} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} = 'attempt' - )`, - }; - }; - - // Hold-specific user data selectors for heatmap using boardsesh_ticks - const getHoldUserLogbookSelects = (climbHoldsTable: typeof tables.climbHolds) => { - return { - userAscents: sql`( - SELECT COUNT(*) - FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${climbHoldsTable.climbUuid} - AND ${boardseshTicks.userId} = ${userId || ''} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} IN ('flash', 'send') - )`, - userAttempts: sql`( - SELECT COUNT(*) - FROM ${boardseshTicks} - WHERE ${boardseshTicks.climbUuid} = ${climbHoldsTable.climbUuid} - AND ${boardseshTicks.userId} = ${userId || ''} - AND ${boardseshTicks.boardType} = ${params.board_name} - AND ${boardseshTicks.angle} = ${params.angle} - AND ${boardseshTicks.status} = 'attempt' - )`, - }; - }; - - return { - // Helper function to get all climb filtering conditions - getClimbWhereConditions: () => [...baseConditions, ...nameCondition, ...setterNameCondition, ...holdConditions, ...holdStateConditions, ...tallClimbsConditions, ...personalProgressConditions], - - // Size-specific conditions - getSizeConditions: () => sizeConditions, - - // Helper function to get all climb stats conditions - getClimbStatsConditions: () => climbStatsConditions, - - // For use in the subquery with left join - includes board_type for unified tables - getClimbStatsJoinConditions: () => [ - eq(tables.climbStats.climbUuid, tables.climbs.uuid), - eq(tables.climbStats.boardType, params.board_name), - eq(tables.climbStats.angle, params.angle), - ], - - // For use in getHoldHeatmapData - includes board_type for unified tables - getHoldHeatmapClimbStatsConditions: () => [ - eq(tables.climbStats.climbUuid, tables.climbHolds.climbUuid), - eq(tables.climbStats.boardType, params.board_name), - eq(tables.climbStats.angle, params.angle), - ], - - // For use when joining climbHolds - includes board_type for unified tables - getClimbHoldsJoinConditions: () => [ - eq(tables.climbHolds.climbUuid, tables.climbs.uuid), - eq(tables.climbHolds.boardType, params.board_name), - ], - - // User-specific logbook data selectors - getUserLogbookSelects, - - // Hold-specific user data selectors for heatmap - getHoldUserLogbookSelects, - - // Raw parts, in case you need direct access to these - baseConditions, - climbStatsConditions, - nameCondition, - setterNameCondition, - holdConditions, - holdStateConditions, - tallClimbsConditions, - sizeConditions, - personalProgressConditions, - anyHolds, - notHolds, - holdStateFilters, - }; -}; diff --git a/packages/web/app/lib/db/queries/climbs/holds-heatmap.ts b/packages/web/app/lib/db/queries/climbs/holds-heatmap.ts deleted file mode 100644 index 3170804d..00000000 --- a/packages/web/app/lib/db/queries/climbs/holds-heatmap.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { and, eq, sql } from 'drizzle-orm'; -import { dbz as db } from '@/app/lib/db/db'; -import { ParsedBoardRouteParameters, SearchRequestPagination } from '@/app/lib/types'; -import { UNIFIED_TABLES } from '@/lib/db/queries/util/table-select'; -import { createClimbFilters } from './create-climb-filters'; -import { getSizeEdges } from '@/app/lib/__generated__/product-sizes-data'; -import { boardseshTicks } from '@/app/lib/db/schema'; - -export interface HoldHeatmapData { - holdId: number; - totalUses: number; - startingUses: number; - totalAscents: number; - handUses: number; - footUses: number; - finishUses: number; - averageDifficulty: number | null; - userAscents?: number; - userAttempts?: number; -} - -export const getHoldHeatmapData = async ( - params: ParsedBoardRouteParameters, - searchParams: SearchRequestPagination, - userId?: string, -): Promise => { - const { climbs, climbStats, climbHolds } = UNIFIED_TABLES; - - // Get hardcoded size edges (eliminates database query) - const sizeEdges = getSizeEdges(params.board_name, params.size_id); - if (!sizeEdges) { - return []; - } - - // Use the shared filter creator with static edge values - const filters = createClimbFilters(params, searchParams, sizeEdges, userId); - - try { - // Check if personal progress filters are active - if so, use user-specific counts - const personalProgressFiltersEnabled = - searchParams.hideAttempted || - searchParams.hideCompleted || - searchParams.showOnlyAttempted || - searchParams.showOnlyCompleted; - - let holdStats: Record[]; - - if (personalProgressFiltersEnabled && userId) { - // When personal progress filters are active, we need to compute user-specific hold statistics - // Since the filters already limit climbs to user's attempted/completed ones, - // we can use the same base query but the results will be user-filtered - const baseQuery = db - .select({ - holdId: climbHolds.holdId, - totalUses: sql`COUNT(DISTINCT ${climbHolds.climbUuid})`, - totalAscents: sql`COUNT(DISTINCT ${climbHolds.climbUuid})`, // For user mode, this represents user's climb count per hold - startingUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'STARTING' THEN 1 ELSE 0 END)`, - handUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'HAND' THEN 1 ELSE 0 END)`, - footUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'FOOT' THEN 1 ELSE 0 END)`, - finishUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'FINISH' THEN 1 ELSE 0 END)`, - averageDifficulty: sql`AVG(${climbStats.displayDifficulty})`, - }) - .from(climbHolds) - .innerJoin(climbs, and(...filters.getClimbHoldsJoinConditions())) - .leftJoin(climbStats, and(...filters.getHoldHeatmapClimbStatsConditions())) - .where( - and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()), - ) - .groupBy(climbHolds.holdId); - - holdStats = await baseQuery; - } else { - // Use global community stats when no personal progress filters are active - const baseQuery = db - .select({ - holdId: climbHolds.holdId, - totalUses: sql`COUNT(DISTINCT ${climbHolds.climbUuid})`, - totalAscents: sql`SUM(${climbStats.ascensionistCount})`, - startingUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'STARTING' THEN 1 ELSE 0 END)`, - handUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'HAND' THEN 1 ELSE 0 END)`, - footUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'FOOT' THEN 1 ELSE 0 END)`, - finishUses: sql`SUM(CASE WHEN ${climbHolds.holdState} = 'FINISH' THEN 1 ELSE 0 END)`, - averageDifficulty: sql`AVG(${climbStats.displayDifficulty})`, - }) - .from(climbHolds) - .innerJoin(climbs, and(...filters.getClimbHoldsJoinConditions())) - .leftJoin(climbStats, and(...filters.getHoldHeatmapClimbStatsConditions())) - .where( - and(...filters.getClimbWhereConditions(), ...filters.getSizeConditions(), ...filters.getClimbStatsConditions()), - ) - .groupBy(climbHolds.holdId); - - holdStats = await baseQuery; - } - - // Add user-specific data only if not already computed in the main query - if (userId && !personalProgressFiltersEnabled) { - // Only fetch separate user data if we're not already using user-specific main stats - // Uses boardsesh_ticks (NextAuth userId) - - // Query for user ascents and attempts per hold in parallel - const [userAscentsQuery, userAttemptsQuery] = await Promise.all([ - db.execute(sql` - SELECT ch.hold_id, COUNT(*) as user_ascents - FROM ${boardseshTicks} t - JOIN board_climb_holds ch ON t.climb_uuid = ch.climb_uuid AND ch.board_type = ${params.board_name} - WHERE t.user_id = ${userId} - AND t.board_type = ${params.board_name} - AND t.angle = ${params.angle} - AND t.status IN ('flash', 'send') - GROUP BY ch.hold_id - `), - db.execute(sql` - SELECT ch.hold_id, SUM(t.attempt_count) as user_attempts - FROM ${boardseshTicks} t - JOIN board_climb_holds ch ON t.climb_uuid = ch.climb_uuid AND ch.board_type = ${params.board_name} - WHERE t.user_id = ${userId} - AND t.board_type = ${params.board_name} - AND t.angle = ${params.angle} - GROUP BY ch.hold_id - `), - ]); - - // Convert results to Maps for easier lookup - const ascentsMap = new Map(); - const attemptsMap = new Map(); - - for (const row of userAscentsQuery.rows) { - ascentsMap.set(Number(row.hold_id), Number(row.user_ascents)); - } - - for (const row of userAttemptsQuery.rows) { - attemptsMap.set(Number(row.hold_id), Number(row.user_attempts)); - } - - // Merge the user data with the hold stats - holdStats = holdStats.map((stat) => ({ - ...stat, - userAscents: ascentsMap.get(Number(stat.holdId)) || 0, - userAttempts: attemptsMap.get(Number(stat.holdId)) || 0, - })); - } else if (personalProgressFiltersEnabled && userId) { - // When using personal progress filters, the main stats ARE the user stats, - // but we still need to provide the userAscents and userAttempts fields - // for backward compatibility with the frontend - holdStats = holdStats.map((stat) => ({ - ...stat, - userAscents: Number(stat.totalAscents) || 0, - userAttempts: Number(stat.totalUses) || 0, - })); - } - - return holdStats.map((stats) => normalizeStats(stats, userId)); - } catch (error) { - console.error('Error in getHoldHeatmapData:', error); - throw error; - } -}; - -function normalizeStats(stats: Record, userId?: string): HoldHeatmapData { - // For numeric fields, ensure we're returning a number and handle null/undefined properly - const result: HoldHeatmapData = { - holdId: Number(stats.holdId), - totalUses: Number(stats.totalUses || 0), - totalAscents: Number(stats.totalAscents || 0), - startingUses: Number(stats.startingUses || 0), - handUses: Number(stats.handUses || 0), - footUses: Number(stats.footUses || 0), - finishUses: Number(stats.finishUses || 0), - averageDifficulty: stats.averageDifficulty ? Number(stats.averageDifficulty) : null, - }; - - // Add user-specific fields if userId was provided - if (userId) { - result.userAscents = Number(stats.userAscents || 0); - result.userAttempts = Number(stats.userAttempts || 0); - } - - return result; -} diff --git a/packages/web/app/lib/db/queries/climbs/setter-stats.ts b/packages/web/app/lib/db/queries/climbs/setter-stats.ts deleted file mode 100644 index eb9b8867..00000000 --- a/packages/web/app/lib/db/queries/climbs/setter-stats.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { eq, sql, and, ilike } from 'drizzle-orm'; -import { dbz as db } from '@/app/lib/db/db'; -import { ParsedBoardRouteParameters } from '@/app/lib/types'; -import { UNIFIED_TABLES } from '@/lib/db/queries/util/table-select'; -import { getSizeEdges } from '@/app/lib/__generated__/product-sizes-data'; - -export interface SetterStat { - setter_username: string; - climb_count: number; -} - -export const getSetterStats = async ( - params: ParsedBoardRouteParameters, - searchQuery?: string, -): Promise => { - const { climbs, climbStats } = UNIFIED_TABLES; - - // Get hardcoded size edges (eliminates database query) - const sizeEdges = getSizeEdges(params.board_name, params.size_id); - if (!sizeEdges) { - return []; - } - - try { - // Build WHERE conditions - const whereConditions = [ - eq(climbs.boardType, params.board_name), - eq(climbs.layoutId, params.layout_id), - eq(climbStats.angle, params.angle), - sql`${climbs.edgeLeft} > ${sizeEdges.edgeLeft}`, - sql`${climbs.edgeRight} < ${sizeEdges.edgeRight}`, - sql`${climbs.edgeBottom} > ${sizeEdges.edgeBottom}`, - sql`${climbs.edgeTop} < ${sizeEdges.edgeTop}`, - sql`${climbs.setterUsername} IS NOT NULL`, - sql`${climbs.setterUsername} != ''`, - ]; - - // Add search filter if provided - if (searchQuery && searchQuery.trim().length > 0) { - whereConditions.push(ilike(climbs.setterUsername, `%${searchQuery}%`)); - } - - const result = await db - .select({ - setter_username: climbs.setterUsername, - climb_count: sql`count(*)::int`, - }) - .from(climbs) - .innerJoin(climbStats, and( - eq(climbStats.climbUuid, climbs.uuid), - eq(climbStats.boardType, params.board_name), - )) - .where(and(...whereConditions)) - .groupBy(climbs.setterUsername) - .orderBy(sql`count(*) DESC`) - .limit(50); // Limit results for performance - - // Filter out any nulls that might have slipped through - return result.filter((stat): stat is SetterStat => stat.setter_username !== null); - } catch (error) { - console.error('Error fetching setter stats:', error); - throw error; - } -}; diff --git a/packages/web/app/lib/graphql/operations/data-queries.ts b/packages/web/app/lib/graphql/operations/data-queries.ts new file mode 100644 index 00000000..bbefaa79 --- /dev/null +++ b/packages/web/app/lib/graphql/operations/data-queries.ts @@ -0,0 +1,277 @@ +import { gql } from 'graphql-request'; +import type { + BetaLink, + ClimbStatsForAngle, + HoldClassification, + UserBoardMapping, + UnsyncedCounts, + SetterStat, + HoldHeatmapStat, +} from '@boardsesh/shared-schema'; + +// ============================================ +// Beta Links +// ============================================ + +export const GET_BETA_LINKS = gql` + query GetBetaLinks($boardName: String!, $climbUuid: String!) { + betaLinks(boardName: $boardName, climbUuid: $climbUuid) { + climbUuid + link + foreignUsername + angle + thumbnail + isListed + createdAt + } + } +`; + +export interface GetBetaLinksQueryVariables { + boardName: string; + climbUuid: string; +} + +export interface GetBetaLinksQueryResponse { + betaLinks: BetaLink[]; +} + +// ============================================ +// Climb Stats +// ============================================ + +export const GET_CLIMB_STATS_FOR_ALL_ANGLES = gql` + query GetClimbStatsForAllAngles($boardName: String!, $climbUuid: String!) { + climbStatsForAllAngles(boardName: $boardName, climbUuid: $climbUuid) { + angle + ascensionistCount + qualityAverage + difficultyAverage + displayDifficulty + faUsername + faAt + difficulty + } + } +`; + +export interface GetClimbStatsForAllAnglesQueryVariables { + boardName: string; + climbUuid: string; +} + +export interface GetClimbStatsForAllAnglesQueryResponse { + climbStatsForAllAngles: ClimbStatsForAngle[]; +} + +// ============================================ +// Hold Classifications +// ============================================ + +export const GET_HOLD_CLASSIFICATIONS = gql` + query GetHoldClassifications($input: GetHoldClassificationsInput!) { + holdClassifications(input: $input) { + id + userId + boardType + layoutId + sizeId + holdId + holdType + handRating + footRating + pullDirection + createdAt + updatedAt + } + } +`; + +export interface GetHoldClassificationsQueryVariables { + input: { + boardType: string; + layoutId: number; + sizeId: number; + }; +} + +export interface GetHoldClassificationsQueryResponse { + holdClassifications: HoldClassification[]; +} + +export const SAVE_HOLD_CLASSIFICATION = gql` + mutation SaveHoldClassification($input: SaveHoldClassificationInput!) { + saveHoldClassification(input: $input) { + id + userId + boardType + layoutId + sizeId + holdId + holdType + handRating + footRating + pullDirection + createdAt + updatedAt + } + } +`; + +export interface SaveHoldClassificationMutationVariables { + input: { + boardType: string; + layoutId: number; + sizeId: number; + holdId: number; + holdType?: string | null; + handRating?: number | null; + footRating?: number | null; + pullDirection?: number | null; + }; +} + +export interface SaveHoldClassificationMutationResponse { + saveHoldClassification: HoldClassification; +} + +// ============================================ +// User Board Mappings +// ============================================ + +export const GET_USER_BOARD_MAPPINGS = gql` + query GetUserBoardMappings { + userBoardMappings { + id + userId + boardType + boardUserId + boardUsername + createdAt + } + } +`; + +export interface GetUserBoardMappingsQueryResponse { + userBoardMappings: UserBoardMapping[]; +} + +export const SAVE_USER_BOARD_MAPPING = gql` + mutation SaveUserBoardMapping($input: SaveUserBoardMappingInput!) { + saveUserBoardMapping(input: $input) + } +`; + +export interface SaveUserBoardMappingMutationVariables { + input: { + boardType: string; + boardUserId: number; + boardUsername?: string | null; + }; +} + +export interface SaveUserBoardMappingMutationResponse { + saveUserBoardMapping: boolean; +} + +// ============================================ +// Unsynced Counts +// ============================================ + +export const GET_UNSYNCED_COUNTS = gql` + query GetUnsyncedCounts { + unsyncedCounts { + kilter { + ascents + climbs + } + tension { + ascents + climbs + } + } + } +`; + +export interface GetUnsyncedCountsQueryResponse { + unsyncedCounts: UnsyncedCounts; +} + +// ============================================ +// Setter Stats +// ============================================ + +export const GET_SETTER_STATS = gql` + query GetSetterStats($input: SetterStatsInput!) { + setterStats(input: $input) { + setterUsername + climbCount + } + } +`; + +export interface GetSetterStatsQueryVariables { + input: { + boardName: string; + layoutId: number; + sizeId: number; + setIds: string; + angle: number; + search?: string | null; + }; +} + +export interface GetSetterStatsQueryResponse { + setterStats: SetterStat[]; +} + +// ============================================ +// Hold Heatmap +// ============================================ + +export const GET_HOLD_HEATMAP = gql` + query GetHoldHeatmap($input: HoldHeatmapInput!) { + holdHeatmap(input: $input) { + holdId + totalUses + startingUses + totalAscents + handUses + footUses + finishUses + averageDifficulty + userAscents + userAttempts + } + } +`; + +export interface GetHoldHeatmapQueryVariables { + input: { + boardName: string; + layoutId: number; + sizeId: number; + setIds: string; + angle: number; + gradeAccuracy?: string | null; + minGrade?: number | null; + maxGrade?: number | null; + minAscents?: number | null; + minRating?: number | null; + sortBy?: string | null; + sortOrder?: string | null; + name?: string | null; + settername?: string[] | null; + onlyClassics?: boolean | null; + onlyTallClimbs?: boolean | null; + holdsFilter?: Record | null; + hideAttempted?: boolean | null; + hideCompleted?: boolean | null; + showOnlyAttempted?: boolean | null; + showOnlyCompleted?: boolean | null; + }; +} + +export interface GetHoldHeatmapQueryResponse { + holdHeatmap: HoldHeatmapStat[]; +} diff --git a/packages/web/app/lib/graphql/operations/index.ts b/packages/web/app/lib/graphql/operations/index.ts index fb41a2db..48875e22 100644 --- a/packages/web/app/lib/graphql/operations/index.ts +++ b/packages/web/app/lib/graphql/operations/index.ts @@ -11,3 +11,5 @@ export * from './activity-feed'; export * from './new-climb-feed'; export * from './sessions'; export * from './create-session'; +export * from './profile'; +export * from './data-queries'; diff --git a/packages/web/app/lib/graphql/operations/profile.ts b/packages/web/app/lib/graphql/operations/profile.ts new file mode 100644 index 00000000..7462e22a --- /dev/null +++ b/packages/web/app/lib/graphql/operations/profile.ts @@ -0,0 +1,167 @@ +import { gql } from 'graphql-request'; + +// ============================================ +// Profile Queries & Mutations +// ============================================ + +export const GET_PROFILE = gql` + query GetProfile { + profile { + id + email + displayName + avatarUrl + instagramUrl + } + } +`; + +export interface GetProfileQueryResponse { + profile: { + id: string; + email: string; + displayName: string | null; + avatarUrl: string | null; + instagramUrl: string | null; + } | null; +} + +export const UPDATE_PROFILE = gql` + mutation UpdateProfile($input: UpdateProfileInput!) { + updateProfile(input: $input) { + id + email + displayName + avatarUrl + instagramUrl + } + } +`; + +export interface UpdateProfileMutationVariables { + input: { + displayName?: string | null; + avatarUrl?: string | null; + instagramUrl?: string | null; + }; +} + +export interface UpdateProfileMutationResponse { + updateProfile: { + id: string; + email: string; + displayName: string | null; + avatarUrl: string | null; + instagramUrl: string | null; + }; +} + +// ============================================ +// Aurora Credentials Queries +// ============================================ + +export const GET_AURORA_CREDENTIALS = gql` + query GetAuroraCredentials { + auroraCredentials { + boardType + username + userId + syncedAt + hasToken + syncStatus + syncError + createdAt + } + } +`; + +export interface AuroraCredentialStatusGql { + boardType: string; + username: string; + userId: number | null; + syncedAt: string | null; + hasToken: boolean; + syncStatus: string | null; + syncError: string | null; + createdAt: string | null; +} + +export interface GetAuroraCredentialsQueryResponse { + auroraCredentials: AuroraCredentialStatusGql[]; +} + +// ============================================ +// Controller Queries & Mutations +// ============================================ + +export const GET_MY_CONTROLLERS = gql` + query GetMyControllers { + myControllers { + id + name + boardName + layoutId + sizeId + setIds + isOnline + lastSeen + createdAt + } + } +`; + +export interface ControllerInfoGql { + id: string; + name: string | null; + boardName: string; + layoutId: number; + sizeId: number; + setIds: string; + isOnline: boolean; + lastSeen: string | null; + createdAt: string; +} + +export interface GetMyControllersQueryResponse { + myControllers: ControllerInfoGql[]; +} + +export const REGISTER_CONTROLLER = gql` + mutation RegisterController($input: RegisterControllerInput!) { + registerController(input: $input) { + apiKey + controllerId + } + } +`; + +export interface RegisterControllerMutationVariables { + input: { + boardName: string; + layoutId: number; + sizeId: number; + setIds: string; + name?: string; + }; +} + +export interface RegisterControllerMutationResponse { + registerController: { + apiKey: string; + controllerId: string; + }; +} + +export const DELETE_CONTROLLER = gql` + mutation DeleteController($controllerId: ID!) { + deleteController(controllerId: $controllerId) + } +`; + +export interface DeleteControllerMutationVariables { + controllerId: string; +} + +export interface DeleteControllerMutationResponse { + deleteController: boolean; +} diff --git a/packages/web/app/lib/graphql/operations/social.ts b/packages/web/app/lib/graphql/operations/social.ts index 9ac0e865..ebda8790 100644 --- a/packages/web/app/lib/graphql/operations/social.ts +++ b/packages/web/app/lib/graphql/operations/social.ts @@ -32,6 +32,7 @@ export const GET_PUBLIC_PROFILE = gql` id displayName avatarUrl + instagramUrl followerCount followingCount isFollowedByMe diff --git a/packages/web/app/lib/url-utils.ts b/packages/web/app/lib/url-utils.ts index 9780295f..a5df0f49 100644 --- a/packages/web/app/lib/url-utils.ts +++ b/packages/web/app/lib/url-utils.ts @@ -268,14 +268,6 @@ export const constructClimbSearchUrl = ( queryString: string, ) => `/api/v1/${board_name}/${layout_id}/${size_id}/${set_ids}/${angle}/search?${queryString}`; -export const constructSetterStatsUrl = ( - { board_name, layout_id, angle, size_id, set_ids }: ParsedBoardRouteParameters, - searchQuery?: string, -) => { - const baseUrl = `/api/v1/${board_name}/${layout_id}/${size_id}/${set_ids}/${angle}/setters`; - return searchQuery ? `${baseUrl}?search=${encodeURIComponent(searchQuery)}` : baseUrl; -}; - // New slug-based URL construction functions export const constructClimbListWithSlugs = ( board_name: string, diff --git a/packages/web/app/settings/settings-page-content.tsx b/packages/web/app/settings/settings-page-content.tsx index e5778b83..dc738529 100644 --- a/packages/web/app/settings/settings-page-content.tsx +++ b/packages/web/app/settings/settings-page-content.tsx @@ -24,6 +24,14 @@ import BackButton from '@/app/components/back-button'; import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; import { usePartyProfile } from '@/app/components/party-manager/party-profile-context'; import { useSnackbar } from '@/app/components/providers/snackbar-provider'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { + GET_PROFILE, + UPDATE_PROFILE, + type GetProfileQueryResponse, + type UpdateProfileMutationResponse, + type UpdateProfileMutationVariables, +} from '@/app/lib/graphql/operations'; const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; @@ -51,13 +59,9 @@ function getBackendHttpUrl(): string | null { interface UserProfile { id: string; email: string; - name: string | null; - image: string | null; - profile: { - displayName: string | null; - avatarUrl: string | null; - instagramUrl: string | null; - } | null; + displayName: string | null; + avatarUrl: string | null; + instagramUrl: string | null; } export default function SettingsPageContent() { @@ -82,12 +86,12 @@ export default function SettingsPageContent() { } }, [status, router]); - // Fetch profile on mount + // Fetch profile on mount (requires authToken for GraphQL) useEffect(() => { - if (status === 'authenticated') { + if (status === 'authenticated' && authToken) { fetchProfile(); } - }, [status]); + }, [status, authToken]); // Clean up preview URL when component unmounts useEffect(() => { @@ -100,17 +104,16 @@ export default function SettingsPageContent() { const fetchProfile = async () => { try { - const response = await fetch('/api/internal/profile'); - if (!response.ok) { - throw new Error('Failed to fetch profile'); + const data = await executeGraphQL(GET_PROFILE, {}, authToken); + if (!data.profile) { + throw new Error('Profile not found'); } - const data = await response.json(); - setProfile(data); + setProfile(data.profile); setFormValues({ - displayName: data.profile?.displayName || data.name || '', - instagramUrl: data.profile?.instagramUrl || '', + displayName: data.profile.displayName || '', + instagramUrl: data.profile.instagramUrl || '', }); - setPreviewUrl(data.profile?.avatarUrl || data.image || undefined); + setPreviewUrl(data.profile.avatarUrl || undefined); } catch (error) { console.error('Failed to fetch profile:', error); showMessage('Failed to load profile', 'error'); @@ -165,7 +168,7 @@ export default function SettingsPageContent() { setSaving(true); - let avatarUrl = profile?.profile?.avatarUrl || profile?.image || null; + let avatarUrl = profile?.avatarUrl || null; // Upload avatar if there's a new file if (selectedFile) { @@ -214,23 +217,18 @@ export default function SettingsPageContent() { } } - // Update profile - const response = await fetch('/api/internal/profile', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', + // Update profile via GraphQL + await executeGraphQL( + UPDATE_PROFILE, + { + input: { + displayName: values.displayName?.trim() || null, + avatarUrl, + instagramUrl: values.instagramUrl?.trim() || null, + }, }, - body: JSON.stringify({ - displayName: values.displayName?.trim() || null, - avatarUrl, - instagramUrl: values.instagramUrl?.trim() || null, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to update profile'); - } + authToken, + ); showMessage('Settings saved successfully', 'success'); setSelectedFile(null); diff --git a/vercel.json b/vercel.json index 8136bb7d..09b5a566 100644 --- a/vercel.json +++ b/vercel.json @@ -3,22 +3,5 @@ "buildCommand": "if [ \"$VERCEL_ENV\" != \"preview\" ]; then npm run db:migrate; fi && npm run build --workspace=@boardsesh/web", "outputDirectory": "packages/web/.next", "framework": "nextjs", - "crons": [ - { - "path": "/api/internal/shared-sync/tension", - "schedule": "0 */2 * * *" - }, - { - "path": "/api/internal/shared-sync/kilter", - "schedule": "0 */2 * * *" - }, - { - "path": "/api/internal/user-sync-cron", - "schedule": "0 */2 * * *" - }, - { - "path": "/api/internal/migrate-users-cron", - "schedule": "0 3 * * *" - } - ] + "crons": [] } From 99ea9724dd12b9f95713d0c771f46bfa204c2138 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Tue, 24 Feb 2026 16:51:19 +0100 Subject: [PATCH 2/3] Fix stale wsAuthToken closure and add tick/logascent tests Use useRef for wsAuthToken in use-save-tick, use-logbook, and use-save-climb hooks so async mutation/query callbacks always access the freshest token value instead of a potentially stale closure capture. Add 20 tests covering LogAscentForm (validation, submission, auth gating, error handling) and TickAction (auth states, badge counts, angle filtering, view modes). Co-Authored-By: Claude Opus 4.6 --- .../__tests__/tick-action.test.tsx | 342 ++++++++++++++++++ .../logbook/__tests__/logascent-form.test.tsx | 331 +++++++++++++++++ packages/web/app/hooks/use-logbook.ts | 7 +- packages/web/app/hooks/use-save-climb.ts | 10 +- packages/web/app/hooks/use-save-tick.ts | 10 +- 5 files changed, 695 insertions(+), 5 deletions(-) create mode 100644 packages/web/app/components/climb-actions/__tests__/tick-action.test.tsx create mode 100644 packages/web/app/components/logbook/__tests__/logascent-form.test.tsx diff --git a/packages/web/app/components/climb-actions/__tests__/tick-action.test.tsx b/packages/web/app/components/climb-actions/__tests__/tick-action.test.tsx new file mode 100644 index 00000000..0670c2f5 --- /dev/null +++ b/packages/web/app/components/climb-actions/__tests__/tick-action.test.tsx @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; +import { render, screen, fireEvent, waitFor, renderHook } from '@testing-library/react'; +import React, { useState } from 'react'; + +// Mock window.matchMedia for ActionTooltip component +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +vi.mock('@vercel/analytics', () => ({ + track: vi.fn(), +})); + +const mockSaveTick = vi.fn(); +vi.mock('../../board-provider/board-provider-context', () => ({ + useBoardProvider: vi.fn(), +})); + +vi.mock('../../auth/auth-modal', () => ({ + __esModule: true, + default: ({ open, onClose, title }: any) => + open ? React.createElement('div', { 'data-testid': 'auth-modal' }, title) : null, +})); + +vi.mock('../../logbook/log-ascent-drawer', () => ({ + LogAscentDrawer: ({ open, onClose }: any) => + open ? React.createElement('div', { 'data-testid': 'log-ascent-drawer' }, 'LogAscentDrawer') : null, +})); + +vi.mock('../../swipeable-drawer/swipeable-drawer', () => ({ + __esModule: true, + default: ({ open, children, title }: any) => + open ? React.createElement('div', { 'data-testid': 'sign-in-drawer' }, [ + React.createElement('span', { key: 'title' }, title), + children, + ]) : null, +})); + +vi.mock('@/app/hooks/use-always-tick-in-app', () => ({ + useAlwaysTickInApp: vi.fn(), +})); + +vi.mock('@/app/lib/url-utils', () => ({ + constructClimbInfoUrl: vi.fn(() => 'https://app.example.com/climb'), +})); + +vi.mock('@/app/theme/theme-config', () => ({ + themeTokens: { + colors: { success: '#00ff00', error: '#ff0000', primary: '#0000ff' }, + spacing: { 4: 16 }, + typography: { fontSize: { base: 14 } }, + }, +})); + +import { useBoardProvider } from '../../board-provider/board-provider-context'; +import { useAlwaysTickInApp } from '@/app/hooks/use-always-tick-in-app'; +import { TickAction } from '../actions/tick-action'; +import type { Climb, BoardDetails } from '@/app/lib/types'; +import type { ClimbActionProps, ClimbActionResult } from '../types'; + +const mockUseBoardProvider = vi.mocked(useBoardProvider); +const mockUseAlwaysTickInApp = vi.mocked(useAlwaysTickInApp); + +const mockClimb: Climb = { + uuid: 'climb-uuid-1', + setter_username: 'setter1', + name: 'Test Climb', + description: 'A test climb', + frames: '', + angle: 40, + ascensionist_count: 5, + difficulty: 'V5', + quality_average: '3.5', + stars: 3, + difficulty_error: '', + litUpHoldsMap: {}, + mirrored: false, + benchmark_difficulty: null, + userAscents: 0, + userAttempts: 0, +}; + +const mockBoardDetails: BoardDetails = { + board_name: 'kilter', + layout_id: 1, + layout_name: 'Original', + size_id: 1, + size_name: '12x12', + set_ids: [1, 2], + set_names: ['Bolt Ons', 'Screw Ons'], + supportsMirroring: true, + angle: 40, + image_url: '', + edge_left: 0, + edge_right: 0, + edge_bottom: 0, + edge_top: 0, +} as BoardDetails; + +const logbookEntry = (overrides: Record = {}) => ({ + uuid: 'tick-1', + climb_uuid: 'climb-uuid-1', + angle: 40, + is_mirror: false, + user_id: 0, + attempt_id: 0, + tries: 1, + quality: 3, + difficulty: 14, + is_benchmark: false, + is_listed: true, + comment: '', + climbed_at: '2024-01-01', + created_at: '2024-01-01', + updated_at: '2024-01-01', + wall_uuid: null, + is_ascent: true, + status: 'flash' as const, + ...overrides, +}); + +/** + * Wrapper component that calls TickAction (which uses hooks) inside a React render context, + * then renders the returned element and exposes the result via data attributes. + */ +function TickActionWrapper(props: ClimbActionProps) { + const result = TickAction(props); + return ( +
+ {result.element} +
+ ); +} + +describe('TickAction', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSaveTick.mockResolvedValue(undefined); + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: true, + logbook: [], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + mockUseAlwaysTickInApp.mockReturnValue({ + alwaysUseApp: false, + loaded: true, + enableAlwaysUseApp: vi.fn(), + }); + }); + + it('returns available=true and key=tick', () => { + render( + , + ); + + const wrapper = screen.getByTestId('tick-action-wrapper'); + expect(wrapper.dataset.available).toBe('true'); + expect(wrapper.dataset.key).toBe('tick'); + }); + + it('renders LogAscentDrawer when authenticated and clicked', async () => { + render( + , + ); + + const button = screen.getByText('Tick'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('log-ascent-drawer')).toBeDefined(); + }); + }); + + it('renders sign-in drawer when not authenticated and clicked', async () => { + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: false, + logbook: [], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + + render( + , + ); + + const button = screen.getByText('Tick'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('sign-in-drawer')).toBeDefined(); + }); + + expect(screen.getByText('Sign in to record ticks')).toBeDefined(); + }); + + it('shows badge with logbook count', () => { + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: true, + logbook: [logbookEntry()], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + + const { container } = render( + , + ); + + const badge = container.querySelector('.MuiBadge-badge'); + expect(badge?.textContent).toBe('1'); + }); + + it('filters logbook entries by angle', () => { + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: true, + logbook: [ + logbookEntry({ angle: 40 }), + logbookEntry({ uuid: 'tick-2', angle: 20 }), // Different angle + ], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + + const { container } = render( + , + ); + + // Should only show badge for angle 40 + const badge = container.querySelector('.MuiBadge-badge'); + expect(badge?.textContent).toBe('1'); + }); + + it('provides menu item config for dropdown mode', () => { + render( + , + ); + + const wrapper = screen.getByTestId('tick-action-wrapper'); + expect(wrapper.dataset.menuLabel).toBe('Tick'); + }); + + it('shows count in menu item label when logbook has entries', () => { + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: true, + logbook: [logbookEntry()], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + + render( + , + ); + + const wrapper = screen.getByTestId('tick-action-wrapper'); + expect(wrapper.dataset.menuLabel).toBe('Tick (1)'); + }); + + it('renders list mode with full-width button', () => { + render( + , + ); + + expect(screen.getByText('Tick')).toBeDefined(); + }); +}); diff --git a/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx b/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx new file mode 100644 index 00000000..5b106639 --- /dev/null +++ b/packages/web/app/components/logbook/__tests__/logascent-form.test.tsx @@ -0,0 +1,331 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +// Mock MUI DateTimePicker to avoid complex setup +vi.mock('@mui/x-date-pickers/DateTimePicker', () => ({ + DateTimePicker: ({ value, onChange, ...props }: any) => + React.createElement('input', { + 'data-testid': 'date-picker', + value: value?.toISOString() || '', + onChange: (e: any) => onChange?.(e.target.value), + }), +})); + +vi.mock('@vercel/analytics', () => ({ + track: vi.fn(), +})); + +const mockSaveTick = vi.fn(); +vi.mock('../../board-provider/board-provider-context', () => ({ + useBoardProvider: vi.fn(), +})); + +vi.mock('@/app/lib/board-data', () => ({ + TENSION_KILTER_GRADES: [ + { difficulty_id: 10, difficulty_name: 'V3' }, + { difficulty_id: 14, difficulty_name: 'V5' }, + { difficulty_id: 18, difficulty_name: 'V7' }, + ], + ANGLES: { + kilter: [0, 10, 20, 30, 40, 50], + tension: [0, 10, 20, 30, 40], + }, +})); + +import { useBoardProvider } from '../../board-provider/board-provider-context'; +import { LogAscentForm } from '../logascent-form'; +import type { Climb, BoardDetails } from '@/app/lib/types'; + +const mockUseBoardProvider = vi.mocked(useBoardProvider); + +const mockClimb: Climb = { + uuid: 'climb-uuid-1', + setter_username: 'setter1', + name: 'Test Climb', + description: 'A test climb', + frames: '', + angle: 40, + ascensionist_count: 5, + difficulty: 'V5', + quality_average: '3.5', + stars: 3, + difficulty_error: '', + litUpHoldsMap: {}, + mirrored: false, + benchmark_difficulty: null, + userAscents: 0, + userAttempts: 0, +}; + +const mockBoardDetails: BoardDetails = { + board_name: 'kilter', + layout_id: 1, + layout_name: 'Original', + size_id: 1, + size_name: '12x12', + set_ids: [1, 2], + set_names: ['Bolt Ons', 'Screw Ons'], + supportsMirroring: true, + angle: 40, + image_url: '', + edge_left: 0, + edge_right: 0, + edge_bottom: 0, + edge_top: 0, +} as BoardDetails; + +describe('LogAscentForm', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSaveTick.mockResolvedValue(undefined); + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: true, + logbook: [], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + }); + + it('renders the form with ascent/attempt toggle', () => { + render( + , + ); + + expect(screen.getByText('Ascent')).toBeDefined(); + expect(screen.getByText('Attempt')).toBeDefined(); + expect(screen.getByText('Submit')).toBeDefined(); + expect(screen.getByText('Cancel')).toBeDefined(); + }); + + it('displays the climb name', () => { + render( + , + ); + + expect(screen.getByText('Test Climb')).toBeDefined(); + }); + + it('shows mirrored chip when board supports mirroring', () => { + render( + , + ); + + expect(screen.getByText('Mirrored')).toBeDefined(); + }); + + it('hides mirrored chip when board does not support mirroring', () => { + const nonMirrorBoard = { ...mockBoardDetails, supportsMirroring: false }; + render( + , + ); + + expect(screen.queryByText('Mirrored')).toBeNull(); + }); + + it('calls saveTick on successful submission with flash status for 1 attempt', async () => { + render( + , + ); + + // Submit with defaults (1 attempt = flash) + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(mockSaveTick).toHaveBeenCalledTimes(1); + }); + + const callArgs = mockSaveTick.mock.calls[0][0]; + expect(callArgs.climbUuid).toBe('climb-uuid-1'); + expect(callArgs.status).toBe('flash'); + expect(callArgs.attemptCount).toBe(1); + }); + + it('calls onClose after successful submission', async () => { + render( + , + ); + + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + }); + + it('does not submit when not authenticated', async () => { + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: false, + logbook: [], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + + render( + , + ); + + fireEvent.click(screen.getByText('Submit')); + + // saveTick should not have been called + expect(mockSaveTick).not.toHaveBeenCalled(); + }); + + it('calls onClose when Cancel is clicked', () => { + render( + , + ); + + fireEvent.click(screen.getByText('Cancel')); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose on save failure', async () => { + mockSaveTick.mockRejectedValue(new Error('Network error')); + + render( + , + ); + + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(mockSaveTick).toHaveBeenCalledTimes(1); + }); + + // onClose should NOT have been called + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('includes layoutId, sizeId, and setIds in saveTick call', async () => { + render( + , + ); + + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(mockSaveTick).toHaveBeenCalledTimes(1); + }); + + const callArgs = mockSaveTick.mock.calls[0][0]; + expect(callArgs.layoutId).toBe(1); + expect(callArgs.sizeId).toBe(1); + expect(callArgs.setIds).toBe('1,2'); + }); +}); + +describe('LogAscentForm validation logic', () => { + // Test the pure helper functions exported from the module + // getTickStatus and validateTickInput are inline, so we test them through the form behavior. + + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockSaveTick.mockResolvedValue(undefined); + mockUseBoardProvider.mockReturnValue({ + saveTick: mockSaveTick, + isAuthenticated: true, + logbook: [], + getLogbook: vi.fn(), + saveClimb: vi.fn(), + isLoading: false, + error: null, + isInitialized: true, + boardName: 'kilter', + }); + }); + + it('does not submit ascent with no climb uuid', async () => { + const noUuidClimb = { ...mockClimb, uuid: '' }; + render( + , + ); + + fireEvent.click(screen.getByText('Submit')); + + expect(mockSaveTick).not.toHaveBeenCalled(); + }); + + it('submits attempt type without quality or difficulty', async () => { + render( + , + ); + + // Switch to attempt mode + fireEvent.click(screen.getByText('Attempt')); + + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(mockSaveTick).toHaveBeenCalledTimes(1); + }); + + const callArgs = mockSaveTick.mock.calls[0][0]; + expect(callArgs.status).toBe('attempt'); + expect(callArgs.quality).toBeUndefined(); + expect(callArgs.difficulty).toBeUndefined(); + }); +}); diff --git a/packages/web/app/hooks/use-logbook.ts b/packages/web/app/hooks/use-logbook.ts index b5ddd293..6a028c2a 100644 --- a/packages/web/app/hooks/use-logbook.ts +++ b/packages/web/app/hooks/use-logbook.ts @@ -1,5 +1,6 @@ 'use client'; +import { useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useWsAuthToken } from './use-ws-auth-token'; import { useSession } from 'next-auth/react'; @@ -73,10 +74,14 @@ export function useLogbook(boardName: BoardName, climbUuids: ClimbUuid[]) { const { token } = useWsAuthToken(); const { status: sessionStatus } = useSession(); + // Use ref to always access the freshest token in async query callbacks + const tokenRef = useRef(token); + tokenRef.current = token; + const query = useQuery({ queryKey: logbookQueryKey(boardName, climbUuids), queryFn: async (): Promise => { - const client = createGraphQLHttpClient(token!); + const client = createGraphQLHttpClient(tokenRef.current!); const variables: GetTicksQueryVariables = { input: { boardType: boardName, diff --git a/packages/web/app/hooks/use-save-climb.ts b/packages/web/app/hooks/use-save-climb.ts index 22f315dd..4c08ab13 100644 --- a/packages/web/app/hooks/use-save-climb.ts +++ b/packages/web/app/hooks/use-save-climb.ts @@ -1,5 +1,6 @@ 'use client'; +import { useRef } from 'react'; import { useMutation } from '@tanstack/react-query'; import { useWsAuthToken } from './use-ws-auth-token'; import { useSession } from 'next-auth/react'; @@ -25,9 +26,14 @@ export function useSaveClimb(boardName: BoardName) { const { data: session, status: sessionStatus } = useSession(); const { showMessage } = useSnackbar(); + // Use ref to always access the freshest token in async mutation callbacks + const tokenRef = useRef(token); + tokenRef.current = token; + return useMutation({ mutationFn: async (options: Omit): Promise => { - if (sessionStatus !== 'authenticated' || !session?.user?.id || !token) { + const currentToken = tokenRef.current; + if (sessionStatus !== 'authenticated' || !session?.user?.id || !currentToken) { throw new Error('Authentication required to create climbs'); } @@ -35,7 +41,7 @@ export function useSaveClimb(boardName: BoardName) { // The client is disposed immediately after the request completes. const client = createGraphQLClient({ url: process.env.NEXT_PUBLIC_WS_URL!, - authToken: token, + authToken: currentToken, }); try { diff --git a/packages/web/app/hooks/use-save-tick.ts b/packages/web/app/hooks/use-save-tick.ts index 293084be..0e8c3199 100644 --- a/packages/web/app/hooks/use-save-tick.ts +++ b/packages/web/app/hooks/use-save-tick.ts @@ -1,5 +1,6 @@ 'use client'; +import { useRef } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useWsAuthToken } from './use-ws-auth-token'; import { useSession } from 'next-auth/react'; @@ -41,16 +42,21 @@ export function useSaveTick(boardName: BoardName) { const { showMessage } = useSnackbar(); const queryClient = useQueryClient(); + // Use ref to always access the freshest token in async mutation callbacks + const tokenRef = useRef(token); + tokenRef.current = token; + return useMutation({ mutationFn: async (options: SaveTickOptions) => { if (sessionStatus !== 'authenticated') { throw new Error('Not authenticated'); } - if (!token) { + const currentToken = tokenRef.current; + if (!currentToken) { throw new Error('Auth token not available'); } - const client = createGraphQLHttpClient(token); + const client = createGraphQLHttpClient(currentToken); const variables: SaveTickMutationVariables = { input: { boardType: boardName, From 73c4fd751d6fccdeb4192a75b6ec80eb2ffc32ca Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Tue, 24 Feb 2026 17:33:06 +0100 Subject: [PATCH 3/3] Escape ILIKE wildcards in setter search and add data-query resolver tests Fix setter search to escape % and _ wildcards so user input is treated literally. Add 26 integration tests for data-query GraphQL resolvers covering input validation, auth requirements, and edge cases. Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/data-queries.test.ts | 601 ++++++++++++++++++ .../graphql/resolvers/data-queries/queries.ts | 4 +- 2 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/__tests__/data-queries.test.ts diff --git a/packages/backend/src/__tests__/data-queries.test.ts b/packages/backend/src/__tests__/data-queries.test.ts new file mode 100644 index 00000000..1157f4a6 --- /dev/null +++ b/packages/backend/src/__tests__/data-queries.test.ts @@ -0,0 +1,601 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient, Client } from 'graphql-ws'; +import WebSocket from 'ws'; +import { startServer } from '../server'; + +const TEST_PORT = 8085; // Different port from other test files + +// Helper to execute GraphQL operations +async function execute( + client: Client, + operation: { query: string; variables?: Record }, +): Promise { + return new Promise((resolve, reject) => { + let result: T; + client.subscribe(operation, { + next: (data) => { + if (data.errors) { + reject(new Error(data.errors[0].message)); + return; + } + result = data.data as T; + }, + error: (err) => reject(err), + complete: () => resolve(result), + }); + }); +} + +// Helper to expect GraphQL errors +async function expectError( + client: Client, + operation: { query: string; variables?: Record }, + expectedMessage?: string, +): Promise { + return new Promise((resolve, reject) => { + client.subscribe(operation, { + next: (data) => { + if (data.errors && data.errors.length > 0) { + if (expectedMessage) { + expect(data.errors[0].message).toContain(expectedMessage); + } + resolve(); + } else { + reject(new Error('Expected GraphQL error but got success')); + } + }, + error: () => resolve(), // Connection errors are also acceptable + complete: () => reject(new Error('Expected error but query completed successfully')), + }); + }); +} + +describe('Data Query Resolver Validation', () => { + let server: Awaited>; + let client: Client; + + beforeAll(async () => { + process.env.PORT = TEST_PORT.toString(); + server = await startServer(); + client = createClient({ + url: `ws://localhost:${TEST_PORT}/graphql`, + webSocketImpl: WebSocket, + }); + }); + + afterAll(async () => { + client.dispose(); + await new Promise((resolve) => { + server.httpServer.close(() => { + server.wss.close(() => { + resolve(); + }); + }); + }); + }); + + // ============================================ + // betaLinks + // ============================================ + describe('betaLinks', () => { + const BETA_LINKS_QUERY = ` + query BetaLinks($boardName: String!, $climbUuid: String!) { + betaLinks(boardName: $boardName, climbUuid: $climbUuid) { + climbUuid + link + foreignUsername + angle + } + } + `; + + it('should reject invalid board name', async () => { + await expectError( + client, + { + query: BETA_LINKS_QUERY, + variables: { boardName: 'invalid-board', climbUuid: 'test-uuid' }, + }, + 'Board name must be kilter, tension, or moonboard', + ); + }); + + it('should reject empty climbUuid', async () => { + await expectError( + client, + { + query: BETA_LINKS_QUERY, + variables: { boardName: 'kilter', climbUuid: '' }, + }, + 'UUID cannot be empty', + ); + }); + + it('should pass validation with valid input', async () => { + // Valid input passes validation but may fail at DB level (tables not in test DB). + // We verify validation passes by checking the error is a DB/query error, not a validation error. + try { + await execute<{ betaLinks: unknown[] }>(client, { + query: BETA_LINKS_QUERY, + variables: { boardName: 'kilter', climbUuid: 'some-climb-uuid' }, + }); + } catch (e: unknown) { + // DB errors are acceptable — the important thing is no validation error + const message = (e as Error).message; + expect(message).not.toContain('Board name must be'); + expect(message).not.toContain('UUID cannot be empty'); + } + }); + }); + + // ============================================ + // climbStatsForAllAngles + // ============================================ + describe('climbStatsForAllAngles', () => { + const CLIMB_STATS_QUERY = ` + query ClimbStatsForAllAngles($boardName: String!, $climbUuid: String!) { + climbStatsForAllAngles(boardName: $boardName, climbUuid: $climbUuid) { + angle + ascensionistCount + qualityAverage + difficulty + } + } + `; + + it('should reject invalid board name', async () => { + await expectError( + client, + { + query: CLIMB_STATS_QUERY, + variables: { boardName: 'notaboard', climbUuid: 'test-uuid' }, + }, + 'Board name must be kilter, tension, or moonboard', + ); + }); + + it('should reject empty climbUuid', async () => { + await expectError( + client, + { + query: CLIMB_STATS_QUERY, + variables: { boardName: 'kilter', climbUuid: '' }, + }, + 'UUID cannot be empty', + ); + }); + + it('should pass validation with valid input', async () => { + // Valid input passes validation but may fail at DB level (tables not in test DB). + try { + await execute<{ climbStatsForAllAngles: unknown[] }>(client, { + query: CLIMB_STATS_QUERY, + variables: { boardName: 'kilter', climbUuid: 'some-climb-uuid' }, + }); + } catch (e: unknown) { + const message = (e as Error).message; + expect(message).not.toContain('Board name must be'); + expect(message).not.toContain('UUID cannot be empty'); + } + }); + }); + + // ============================================ + // holdClassifications (requires auth) + // ============================================ + describe('holdClassifications', () => { + const HOLD_CLASSIFICATIONS_QUERY = ` + query HoldClassifications($input: GetHoldClassificationsInput!) { + holdClassifications(input: $input) { + id + holdId + holdType + } + } + `; + + it('should reject unauthenticated request', async () => { + await expectError( + client, + { + query: HOLD_CLASSIFICATIONS_QUERY, + variables: { + input: { boardType: 'kilter', layoutId: 1, sizeId: 1 }, + }, + }, + 'Authentication required', + ); + }); + + it('should reject invalid board type', async () => { + await expectError( + client, + { + query: HOLD_CLASSIFICATIONS_QUERY, + variables: { + input: { boardType: 'invalid', layoutId: 1, sizeId: 1 }, + }, + }, + ); + }); + + it('should reject non-positive layoutId', async () => { + await expectError( + client, + { + query: HOLD_CLASSIFICATIONS_QUERY, + variables: { + input: { boardType: 'kilter', layoutId: 0, sizeId: 1 }, + }, + }, + ); + }); + }); + + // ============================================ + // userBoardMappings (requires auth) + // ============================================ + describe('userBoardMappings', () => { + const USER_BOARD_MAPPINGS_QUERY = ` + query UserBoardMappings { + userBoardMappings { + id + boardType + boardUserId + } + } + `; + + it('should reject unauthenticated request', async () => { + await expectError( + client, + { query: USER_BOARD_MAPPINGS_QUERY }, + 'Authentication required', + ); + }); + }); + + // ============================================ + // unsyncedCounts (requires auth) + // ============================================ + describe('unsyncedCounts', () => { + const UNSYNCED_COUNTS_QUERY = ` + query UnsyncedCounts { + unsyncedCounts { + kilter { ascents climbs } + tension { ascents climbs } + } + } + `; + + it('should reject unauthenticated request', async () => { + await expectError( + client, + { query: UNSYNCED_COUNTS_QUERY }, + 'Authentication required', + ); + }); + }); + + // ============================================ + // setterStats + // ============================================ + describe('setterStats', () => { + const SETTER_STATS_QUERY = ` + query SetterStats($input: SetterStatsInput!) { + setterStats(input: $input) { + setterUsername + climbCount + } + } + `; + + it('should reject invalid board name', async () => { + await expectError( + client, + { + query: SETTER_STATS_QUERY, + variables: { + input: { + boardName: 'invalid-board', + layoutId: 1, + sizeId: 1, + setIds: '1', + angle: 40, + }, + }, + }, + 'Board name must be kilter, tension, or moonboard', + ); + }); + + it('should reject non-positive layoutId', async () => { + await expectError( + client, + { + query: SETTER_STATS_QUERY, + variables: { + input: { + boardName: 'kilter', + layoutId: -1, + sizeId: 1, + setIds: '1', + angle: 40, + }, + }, + }, + ); + }); + + it('should reject empty setIds', async () => { + await expectError( + client, + { + query: SETTER_STATS_QUERY, + variables: { + input: { + boardName: 'kilter', + layoutId: 1, + sizeId: 1, + setIds: '', + angle: 40, + }, + }, + }, + ); + }); + + it('should accept valid input with search parameter', async () => { + const result = await execute<{ setterStats: unknown[] }>(client, { + query: SETTER_STATS_QUERY, + variables: { + input: { + boardName: 'kilter', + layoutId: 1, + sizeId: 1, + setIds: '1', + angle: 40, + search: 'testuser', + }, + }, + }); + + expect(result.setterStats).toBeDefined(); + expect(Array.isArray(result.setterStats)).toBe(true); + }); + + it('should handle LIKE wildcard characters in search safely', async () => { + // Searching for literal % and _ should not cause unexpected behavior + const result = await execute<{ setterStats: unknown[] }>(client, { + query: SETTER_STATS_QUERY, + variables: { + input: { + boardName: 'kilter', + layoutId: 1, + sizeId: 1, + setIds: '1', + angle: 40, + search: '100%_user', + }, + }, + }); + + expect(result.setterStats).toBeDefined(); + expect(Array.isArray(result.setterStats)).toBe(true); + }); + + it('should return empty array for moonboard', async () => { + const result = await execute<{ setterStats: unknown[] }>(client, { + query: SETTER_STATS_QUERY, + variables: { + input: { + boardName: 'moonboard', + layoutId: 1, + sizeId: 1, + setIds: '1', + angle: 40, + }, + }, + }); + + expect(result.setterStats).toEqual([]); + }); + }); + + // ============================================ + // holdHeatmap + // ============================================ + describe('holdHeatmap', () => { + const HOLD_HEATMAP_QUERY = ` + query HoldHeatmap($input: HoldHeatmapInput!) { + holdHeatmap(input: $input) { + holdId + totalUses + startingUses + totalAscents + } + } + `; + + it('should reject invalid board name', async () => { + await expectError( + client, + { + query: HOLD_HEATMAP_QUERY, + variables: { + input: { + boardName: 'fake-board', + layoutId: 1, + sizeId: 1, + setIds: '1', + angle: 40, + }, + }, + }, + 'Board name must be kilter, tension, or moonboard', + ); + }); + + it('should reject non-positive layoutId', async () => { + await expectError( + client, + { + query: HOLD_HEATMAP_QUERY, + variables: { + input: { + boardName: 'kilter', + layoutId: 0, + sizeId: 1, + setIds: '1', + angle: 40, + }, + }, + }, + ); + }); + + it('should return empty array for moonboard', async () => { + const result = await execute<{ holdHeatmap: unknown[] }>(client, { + query: HOLD_HEATMAP_QUERY, + variables: { + input: { + boardName: 'moonboard', + layoutId: 1, + sizeId: 1, + setIds: '1', + angle: 40, + }, + }, + }); + + expect(result.holdHeatmap).toEqual([]); + }); + }); + + // ============================================ + // saveHoldClassification (requires auth) + // ============================================ + describe('saveHoldClassification', () => { + const SAVE_HOLD_CLASSIFICATION_MUTATION = ` + mutation SaveHoldClassification($input: SaveHoldClassificationInput!) { + saveHoldClassification(input: $input) { + id + holdId + holdType + } + } + `; + + it('should reject unauthenticated request', async () => { + await expectError( + client, + { + query: SAVE_HOLD_CLASSIFICATION_MUTATION, + variables: { + input: { + boardType: 'kilter', + layoutId: 1, + sizeId: 1, + holdId: 42, + holdType: 'crimp', + }, + }, + }, + 'Authentication required', + ); + }); + + it('should reject invalid board type', async () => { + await expectError( + client, + { + query: SAVE_HOLD_CLASSIFICATION_MUTATION, + variables: { + input: { + boardType: 'invalid', + layoutId: 1, + sizeId: 1, + holdId: 42, + }, + }, + }, + ); + }); + + it('should reject non-positive holdId', async () => { + await expectError( + client, + { + query: SAVE_HOLD_CLASSIFICATION_MUTATION, + variables: { + input: { + boardType: 'kilter', + layoutId: 1, + sizeId: 1, + holdId: -1, + }, + }, + }, + ); + }); + }); + + // ============================================ + // saveUserBoardMapping (requires auth) + // ============================================ + describe('saveUserBoardMapping', () => { + const SAVE_USER_BOARD_MAPPING_MUTATION = ` + mutation SaveUserBoardMapping($input: SaveUserBoardMappingInput!) { + saveUserBoardMapping(input: $input) + } + `; + + it('should reject unauthenticated request', async () => { + await expectError( + client, + { + query: SAVE_USER_BOARD_MAPPING_MUTATION, + variables: { + input: { + boardType: 'kilter', + boardUserId: 12345, + boardUsername: 'climber1', + }, + }, + }, + 'Authentication required', + ); + }); + + it('should reject invalid board type (moonboard not allowed)', async () => { + await expectError( + client, + { + query: SAVE_USER_BOARD_MAPPING_MUTATION, + variables: { + input: { + boardType: 'moonboard', + boardUserId: 12345, + }, + }, + }, + 'Board type must be kilter or tension', + ); + }); + + it('should reject non-positive boardUserId', async () => { + await expectError( + client, + { + query: SAVE_USER_BOARD_MAPPING_MUTATION, + variables: { + input: { + boardType: 'kilter', + boardUserId: 0, + }, + }, + }, + ); + }); + }); +}); diff --git a/packages/backend/src/graphql/resolvers/data-queries/queries.ts b/packages/backend/src/graphql/resolvers/data-queries/queries.ts index d491137d..c7405f3f 100644 --- a/packages/backend/src/graphql/resolvers/data-queries/queries.ts +++ b/packages/backend/src/graphql/resolvers/data-queries/queries.ts @@ -263,7 +263,9 @@ export const dataQueryQueries = { ]; if (validatedInput.search && validatedInput.search.trim().length > 0) { - whereConditions.push(ilike(climbs.setterUsername, `%${validatedInput.search}%`)); + // Escape LIKE wildcards (% and _) so user input is treated literally + const escapedSearch = validatedInput.search.replace(/%/g, '\\%').replace(/_/g, '\\_'); + whereConditions.push(ilike(climbs.setterUsername, `%${escapedSearch}%`)); } const result = await db