From abdc4e4d52a7d1cee0e340b69a328e8220dde526 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 13 Feb 2026 15:32:45 +0100 Subject: [PATCH 1/2] 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 | 27 +- .../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 | 42 +- .../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, 1955 insertions(+), 3629 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 dca76660c..e076395fa 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 000000000..4e6a1167d --- /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 000000000..d491137d9 --- /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 91c8f554a..862f5c610 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 4e0c3013d..a6a6af6ae 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 063c21867..4ac1542c6 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 f672f2d7e..7aa31f131 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 c31ecf177..bd35d0c63 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 eaa84a330..897062020 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 } """ @@ -1987,6 +1997,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" @@ -2446,6 +2458,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. """ @@ -2656,6 +2848,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 # ============================================ @@ -3040,6 +3276,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 db675f69f..b31d4977b 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 aa505091d..000000000 --- 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 65093275a..000000000 --- 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 e8e846a67..000000000 --- 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 cc38e4590..000000000 --- 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 8164896d2..000000000 --- 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 d5cd3e072..000000000 --- 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 299474cc4..000000000 --- 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 a893bee4a..000000000 --- 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 fb9cd8459..000000000 --- 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 a7cff26cf..000000000 --- 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 9324c99cd..000000000 --- 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 d6d72e78d..000000000 --- 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 9cf5b9dca..000000000 --- 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 bdb99c10d..000000000 --- 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 32568631f..000000000 --- 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 761053c70..000000000 --- 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 0d8b63efc..000000000 --- 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 695269865..000000000 --- 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 db9b95b20..000000000 --- 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 8abfd2a7f..f470cf2ec 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 c198e15d0..9d283bbb6 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 useSWR from 'swr'; 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'; @@ -32,15 +34,22 @@ export default function AngleSelector({ boardName, boardDetails, currentAngle, c const pathname = usePathname(); const currentAngleRef = useRef(null); - // Build the API URL for fetching climb stats - const climbStatsUrl = currentClimb - ? `/api/v1/${boardName}/climb-stats/${currentClimb.uuid}` + // Build cache key for fetching climb stats + const climbStatsKey = currentClimb + ? `climbStats:${boardName}:${currentClimb.uuid}` : null; // Fetch climb stats for all angles when there's a current climb const { data: climbStats, isLoading } = useSWR( - climbStatsUrl, - (url: string) => fetch(url).then(res => res.json()), + climbStatsKey, + async () => { + if (!currentClimb) return []; + const data = await executeGraphQL( + GET_CLIMB_STATS_FOR_ALL_ANGLES, + { boardName, climbUuid: currentClimb.uuid }, + ); + return data.climbStatsForAllAngles; + }, { revalidateOnFocus: false, revalidateOnReconnect: false, @@ -125,13 +134,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 ada416c3e..4ecc4ddfe 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 9e1054b1b..d2c2cb3a3 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 e9823b7e3..73c36c9d4 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 @@ -5,8 +5,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; @@ -36,14 +38,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 92415caa4..225ea98aa 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 de42d37c4..e193a86fe 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 04daf12ed..41006d16c 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 6341c241c..bf0bcf74d 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 useSWR from 'swr'; -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,15 +29,28 @@ 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) + // Build cache key for SWR + const cacheKey = shouldFetch + ? `setterStats:${parsedParams.board_name}:${parsedParams.layout_id}:${parsedParams.size_id}:${parsedParams.set_ids}:${parsedParams.angle}:${isSearching ? searchValue : ''}` : null; - // Fetch setter stats from the API + // Fetch setter stats via GraphQL const { data: setterStats, isLoading } = useSWR( - apiUrl, - fetcher, + cacheKey, + 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; + }, { revalidateOnFocus: false, revalidateOnReconnect: false, @@ -55,9 +63,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 645fc1acc..8f5ebc272 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 73c3818e5..92068539a 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 650287942..694b7406a 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 d49541f81..e20abcd9d 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: Number(item.benchmark_difficulty), - 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: Number(item.benchmark_difficulty), - 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: Number(item.benchmark_difficulty), - 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 44eefef58..77aa439e7 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 e69de29bb..000000000 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 f8b9fa8bd..000000000 --- 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 3170804d8..000000000 --- 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 eb9b8867a..000000000 --- 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 000000000..bbefaa79e --- /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 fb41a2db2..48875e22a 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 000000000..7462e22aa --- /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 9ac0e8652..ebda8790b 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 9780295f6..a5df0f490 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 e5778b83f..dc7385298 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 8136bb7d9..09b5a566b 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 6873fafd6beb93ac89778836e329ebe880c84479 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Fri, 13 Feb 2026 15:42:38 +0100 Subject: [PATCH 2/2] Fix /my-library production errors and show public playlists for non-auth users - Fix hydration mismatch (#418) by adding hasMounted guard in library-page-content - Switch useClimbActionsData to GET_ALL_USER_PLAYLISTS to avoid empty boardType/layoutId errors - Add useOptionalBoardProvider hook so queue components don't throw outside BoardProvider - Replace full-page sign-in block with compact banner, show Discover for all users Co-Authored-By: Claude Opus 4.6 --- .../board-provider/board-provider-context.tsx | 4 + .../app/components/library/library.module.css | 17 +++ .../hooks/use-queue-data-fetching.tsx | 6 +- .../queue-control/queue-list-item.tsx | 6 +- .../components/queue-control/queue-list.tsx | 4 +- .../web/app/hooks/use-climb-actions-data.tsx | 29 ++--- .../[[...filter]]/library-page-content.tsx | 108 +++++++----------- 7 files changed, 88 insertions(+), 86 deletions(-) diff --git a/packages/web/app/components/board-provider/board-provider-context.tsx b/packages/web/app/components/board-provider/board-provider-context.tsx index 2b2f5cc5c..fd9559c10 100644 --- a/packages/web/app/components/board-provider/board-provider-context.tsx +++ b/packages/web/app/components/board-provider/board-provider-context.tsx @@ -355,5 +355,9 @@ export function useBoardProvider() { return context; } +export function useOptionalBoardProvider(): BoardContextType | null { + return useContext(BoardContext) ?? null; +} + export type { BoardContextType }; export { BoardContext }; diff --git a/packages/web/app/components/library/library.module.css b/packages/web/app/components/library/library.module.css index a87aa3135..6a7cbb780 100644 --- a/packages/web/app/components/library/library.module.css +++ b/packages/web/app/components/library/library.module.css @@ -230,6 +230,23 @@ background: linear-gradient(135deg, var(--color-error), #D87F7A); } +/* Sign-in Banner (compact, non-blocking) */ +.signInBanner { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background-color: var(--semantic-surface); + border-radius: 12px; + box-shadow: var(--shadow-sm); + margin-bottom: 20px; +} + +.signInBannerText { + flex: 1; + min-width: 0; +} + /* Empty/Loading/Error States */ .emptyContainer { display: flex; diff --git a/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx b/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx index 06f7d69a5..9df59fc1a 100644 --- a/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx +++ b/packages/web/app/components/queue-control/hooks/use-queue-data-fetching.tsx @@ -3,7 +3,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { PAGE_LIMIT } from '../../board-page/constants'; import { ClimbQueue } from '../types'; import { ParsedBoardRouteParameters, SearchRequestPagination, SearchClimbsResult } from '@/app/lib/types'; -import { useBoardProvider } from '../../board-provider/board-provider-context'; +import { useOptionalBoardProvider } from '../../board-provider/board-provider-context'; import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; import { SEARCH_CLIMBS, type ClimbSearchResponse } from '@/app/lib/graphql/operations/climb-search'; import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; @@ -23,7 +23,7 @@ export const useQueueDataFetching = ({ hasDoneFirstFetch, setHasDoneFirstFetch, }: UseQueueDataFetchingProps) => { - const { getLogbook } = useBoardProvider(); + const getLogbook = useOptionalBoardProvider()?.getLogbook; // Use wsAuthToken for GraphQL backend auth (NextAuth session token) const { token: wsAuthToken } = useWsAuthToken(); const fetchedUuidsRef = useRef(''); @@ -162,7 +162,7 @@ export const useQueueDataFetching = ({ } const climbUuids = JSON.parse(climbUuidsString); - if (climbUuids.length > 0) { + if (climbUuids.length > 0 && getLogbook) { // Only mark as fetched if the fetch actually succeeded // This ensures we retry when wsAuthToken becomes available getLogbook(climbUuids).then((success) => { diff --git a/packages/web/app/components/queue-control/queue-list-item.tsx b/packages/web/app/components/queue-control/queue-list-item.tsx index 216c082e0..79b94b0f7 100644 --- a/packages/web/app/components/queue-control/queue-list-item.tsx +++ b/packages/web/app/components/queue-control/queue-list-item.tsx @@ -28,7 +28,7 @@ import { useSwipeActions } from '@/app/hooks/use-swipe-actions'; import { ClimbQueueItem } from './types'; import ClimbThumbnail from '../climb-card/climb-thumbnail'; import ClimbTitle from '../climb-card/climb-title'; -import { useBoardProvider } from '../board-provider/board-provider-context'; +import { useOptionalBoardProvider } from '../board-provider/board-provider-context'; import { themeTokens } from '@/app/theme/theme-config'; import { getGradeTintColor } from '@/app/lib/grade-colors'; import { useColorMode } from '@/app/hooks/use-color-mode'; @@ -53,7 +53,9 @@ type QueueListItemProps = { }; export const AscentStatus = ({ climbUuid, fontSize }: { climbUuid: ClimbUuid; fontSize?: number }) => { - const { logbook, boardName } = useBoardProvider(); + const boardProvider = useOptionalBoardProvider(); + const logbook = boardProvider?.logbook ?? []; + const boardName = boardProvider?.boardName ?? 'kilter'; const ascentsForClimb = useMemo( () => logbook.filter((ascent) => ascent.climb_uuid === climbUuid), diff --git a/packages/web/app/components/queue-control/queue-list.tsx b/packages/web/app/components/queue-control/queue-list.tsx index a3a779dac..e89a9a0a6 100644 --- a/packages/web/app/components/queue-control/queue-list.tsx +++ b/packages/web/app/components/queue-control/queue-list.tsx @@ -19,7 +19,7 @@ import ClimbThumbnail from '../climb-card/climb-thumbnail'; import ClimbTitle from '../climb-card/climb-title'; import { themeTokens } from '@/app/theme/theme-config'; import { SUGGESTIONS_THRESHOLD } from '../board-page/constants'; -import { useBoardProvider } from '../board-provider/board-provider-context'; +import { useOptionalBoardProvider } from '../board-provider/board-provider-context'; import { LogAscentDrawer } from '../logbook/log-ascent-drawer'; import AuthModal from '../auth/auth-modal'; import styles from './queue-list.module.css'; @@ -55,7 +55,7 @@ const QueueList = forwardRef(({ boardDetails, o removeFromQueue, } = useQueueContext(); - const { isAuthenticated } = useBoardProvider(); + const isAuthenticated = useOptionalBoardProvider()?.isAuthenticated ?? false; // Tick drawer state const [tickDrawerVisible, setTickDrawerVisible] = useState(false); diff --git a/packages/web/app/hooks/use-climb-actions-data.tsx b/packages/web/app/hooks/use-climb-actions-data.tsx index 08189c0ba..8a5484f84 100644 --- a/packages/web/app/hooks/use-climb-actions-data.tsx +++ b/packages/web/app/hooks/use-climb-actions-data.tsx @@ -12,12 +12,13 @@ import { type ToggleFavoriteMutationResponse, } from '@/app/lib/graphql/operations/favorites'; import { - GET_USER_PLAYLISTS, + GET_ALL_USER_PLAYLISTS, GET_PLAYLISTS_FOR_CLIMB, ADD_CLIMB_TO_PLAYLIST, REMOVE_CLIMB_FROM_PLAYLIST, CREATE_PLAYLIST, - type GetUserPlaylistsQueryResponse, + type GetAllUserPlaylistsQueryResponse, + type GetAllUserPlaylistsInput, type GetPlaylistsForClimbQueryResponse, type AddClimbToPlaylistMutationResponse, type RemoveClimbFromPlaylistMutationResponse, @@ -70,7 +71,7 @@ export function useClimbActionsData({ throw new Error(`Failed to fetch favorites: ${errorMessage}`); } }, - enabled: isAuthenticated && !isAuthLoading && sortedClimbUuids.length > 0, + enabled: isAuthenticated && !isAuthLoading && sortedClimbUuids.length > 0 && !!boardName, staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }); @@ -137,18 +138,18 @@ export function useClimbActionsData({ ); const [playlistsLoading, setPlaylistsLoading] = useState(false); - // Fetch user's playlists + // Fetch user's playlists (all boards) useEffect(() => { - if (!token || !isAuthenticated || boardName === 'moonboard') return; + if (!token || !isAuthenticated) return; const fetchPlaylists = async () => { try { setPlaylistsLoading(true); const client = createGraphQLHttpClient(token); - const response = await client.request(GET_USER_PLAYLISTS, { - input: { boardType: boardName, layoutId }, + const response = await client.request(GET_ALL_USER_PLAYLISTS, { + input: {}, }); - setPlaylists(response.userPlaylists); + setPlaylists(response.allUserPlaylists); } catch (error) { console.error('Failed to fetch playlists:', error); setPlaylists([]); @@ -158,7 +159,7 @@ export function useClimbActionsData({ }; fetchPlaylists(); - }, [token, isAuthenticated, boardName, layoutId]); + }, [token, isAuthenticated]); // Fetch playlist memberships for visible climbs const climbUuidsKey = useMemo(() => sortedClimbUuids.join(','), [sortedClimbUuids]); @@ -183,7 +184,7 @@ export function useClimbActionsData({ return memberships; }, enabled: - isAuthenticated && !isAuthLoading && sortedClimbUuids.length > 0 && boardName !== 'moonboard', + isAuthenticated && !isAuthLoading && sortedClimbUuids.length > 0 && !!boardName && layoutId > 0 && boardName !== 'moonboard', staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }); @@ -262,16 +263,16 @@ export function useClimbActionsData({ try { setPlaylistsLoading(true); const client = createGraphQLHttpClient(token); - const response = await client.request(GET_USER_PLAYLISTS, { - input: { boardType: boardName, layoutId }, + const response = await client.request(GET_ALL_USER_PLAYLISTS, { + input: {}, }); - setPlaylists(response.userPlaylists); + setPlaylists(response.allUserPlaylists); } catch (error) { console.error('Failed to refresh playlists:', error); } finally { setPlaylistsLoading(false); } - }, [token, boardName, layoutId]); + }, [token]); return { favoritesProviderProps: { diff --git a/packages/web/app/my-library/[[...filter]]/library-page-content.tsx b/packages/web/app/my-library/[[...filter]]/library-page-content.tsx index 603a2dcc9..e9425ce30 100644 --- a/packages/web/app/my-library/[[...filter]]/library-page-content.tsx +++ b/packages/web/app/my-library/[[...filter]]/library-page-content.tsx @@ -50,9 +50,14 @@ export default function LibraryPageContent({ const router = useRouter(); const isAuthenticated = sessionStatus === 'authenticated'; + const [hasMounted, setHasMounted] = useState(false); const selectedBoard = boardFilter ?? 'all'; const [showAuthModal, setShowAuthModal] = useState(false); + useEffect(() => { + setHasMounted(true); + }, []); + // Data states const [playlists, setPlaylists] = useState([]); const [activeBoards, setActiveBoards] = useState([]); @@ -199,59 +204,8 @@ export default function LibraryPageContent({
); - // Not authenticated state - if (!isAuthenticated && sessionStatus !== 'loading') { - return ( - <> - {renderHeader()} -
- - - Sign in to view your library - - - Create and manage your own climb playlists by signing in. - - } - onClick={() => setShowAuthModal(true)} - sx={{ mt: 2 }} - > - Sign In - -
- - {/* Still show discover section for non-authenticated users */} - {!discoverLoading && getDiscoverPlaylists().length > 0 && ( - - {getDiscoverPlaylists().map((p, i) => ( - - ))} - - )} - - setShowAuthModal(false)} - title="Sign in to Boardsesh" - description="Sign in to create and manage your climb playlists." - /> - - ); - } - - // Error state - if (error) { + // Error state (only for authenticated users with fetch errors) + if (isAuthenticated && error) { return ( <> {renderHeader()} @@ -269,7 +223,7 @@ export default function LibraryPageContent({ ); } - const isLoading = playlistsLoading || tokenLoading || sessionStatus === 'loading'; + const isLoading = !hasMounted || playlistsLoading || tokenLoading || sessionStatus === 'loading'; const discoverItems = getDiscoverPlaylists(); // Filter playlists by selected board @@ -281,16 +235,40 @@ export default function LibraryPageContent({ <> {renderHeader()} - {/* Recent Playlists Grid */} - + {/* Sign-in banner for non-authenticated users */} + {hasMounted && !isAuthenticated && sessionStatus !== 'loading' && ( +
+ +
+ + Sign in to create playlists + + + Manage your own climb playlists by signing in. + +
+ setShowAuthModal(true)} + > + Sign In + +
+ )} + + {/* Authenticated: Recent Playlists Grid */} + {isAuthenticated && ( + + )} - {/* Empty state if no playlists */} - {!isLoading && playlists.length === 0 && ( + {/* Empty state if no playlists (authenticated only) */} + {isAuthenticated && !isLoading && playlists.length === 0 && (
@@ -302,8 +280,8 @@ export default function LibraryPageContent({
)} - {/* Jump Back In */} - {(isLoading || filteredPlaylists.length > 0) && ( + {/* Jump Back In (authenticated only) */} + {isAuthenticated && (isLoading || filteredPlaylists.length > 0) && ( {filteredPlaylists.slice(0, 10).map((p, i) => (