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": [] }