From 5eb8a99f5f9568ea186408899473e717676d0d61 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sat, 14 Feb 2026 11:15:01 +0100 Subject: [PATCH 1/5] Add setter follows, profiles, and unified search (#812) Allow users to follow setters (including non-Boardsesh users synced from Aurora API), view setter profiles with created climbs, and search for both users and setters in a unified search. Key changes: - Database: setter_follows table, new_climbs_synced notification type - Backend: setter follow/unfollow, profile, climbs, unified search resolvers - Shared sync: detect new climbs and notify setter followers - Frontend: /setter/[username] profile page, unified search results, created climbs on user profiles, notification display Co-Authored-By: Claude Opus 4.6 --- .../backend/src/graphql/resolvers/index.ts | 3 + .../graphql/resolvers/social/notifications.ts | 20 +- .../resolvers/social/setter-follows.ts | 462 ++ packages/backend/src/validation/schemas.ts | 24 + .../db/drizzle/0057_volatile_miss_america.sql | 13 + packages/db/drizzle/meta/0057_snapshot.json | 7254 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/app/follows.ts | 14 + packages/db/src/schema/app/notifications.ts | 4 +- packages/shared-schema/src/schema.ts | 160 + packages/shared-schema/src/types.ts | 72 +- .../shared-sync/[board_name]/route.ts | 143 +- .../climb-list/setter-climb-list.tsx | 185 + .../notifications/notification-item.tsx | 5 + .../notifications/notification-list.tsx | 2 + .../components/social/user-search-results.tsx | 171 +- .../[user_id]/profile-page-content.tsx | 32 + .../app/lib/data-sync/aurora/shared-sync.ts | 56 +- .../lib/graphql/operations/notifications.ts | 1 + .../web/app/lib/graphql/operations/social.ts | 133 + .../web/app/setter/[setter_username]/page.tsx | 10 + .../setter-profile-content.tsx | 174 + .../setter-profile.module.css | 140 + 23 files changed, 9012 insertions(+), 73 deletions(-) create mode 100644 packages/backend/src/graphql/resolvers/social/setter-follows.ts create mode 100644 packages/db/drizzle/0057_volatile_miss_america.sql create mode 100644 packages/db/drizzle/meta/0057_snapshot.json create mode 100644 packages/web/app/components/climb-list/setter-climb-list.tsx create mode 100644 packages/web/app/setter/[setter_username]/page.tsx create mode 100644 packages/web/app/setter/[setter_username]/setter-profile-content.tsx create mode 100644 packages/web/app/setter/[setter_username]/setter-profile.module.css diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index 91c8f554..0a6be412 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -26,6 +26,7 @@ import { controllerMutations } from './controller/mutations'; import { controllerSubscriptions, controllerEventResolver } from './controller/subscriptions'; import { socialFollowQueries, socialFollowMutations } from './social/follows'; import { socialSearchQueries } from './social/search'; +import { setterFollowQueries, setterFollowMutations } from './social/setter-follows'; import { socialFeedQueries } from './social/feed'; import { activityFeedQueries } from './social/activity-feed'; import { socialCommentQueries, socialCommentMutations } from './social/comments'; @@ -57,6 +58,7 @@ export const resolvers = { ...controllerQueries, ...socialFollowQueries, ...socialSearchQueries, + ...setterFollowQueries, ...socialFeedQueries, ...socialCommentQueries, ...socialVoteQueries, @@ -80,6 +82,7 @@ export const resolvers = { ...playlistMutations, ...controllerMutations, ...socialFollowMutations, + ...setterFollowMutations, ...socialCommentMutations, ...socialVoteMutations, ...socialBoardMutations, diff --git a/packages/backend/src/graphql/resolvers/social/notifications.ts b/packages/backend/src/graphql/resolvers/social/notifications.ts index 5ecc1a30..9854b059 100644 --- a/packages/backend/src/graphql/resolvers/social/notifications.ts +++ b/packages/backend/src/graphql/resolvers/social/notifications.ts @@ -218,6 +218,7 @@ export const socialNotificationQueries = { climbUuid: undefined as string | undefined, boardType: undefined as string | undefined, proposalUuid: undefined as string | undefined, + setterUsername: undefined as string | undefined, isRead: row.allRead, createdAt: row.latestCreatedAt instanceof Date ? row.latestCreatedAt.toISOString() @@ -232,7 +233,24 @@ export const socialNotificationQueries = { for (const group of groups) { if (!group.entityId) continue; - if (climbTypes.includes(group.type)) { + if (group.type === 'new_climbs_synced') { + // For synced climb notifications, entityId is the first climb UUID + const [climb] = await db + .select({ + name: dbSchema.boardClimbs.name, + boardType: dbSchema.boardClimbs.boardType, + setterUsername: dbSchema.boardClimbs.setterUsername, + }) + .from(dbSchema.boardClimbs) + .where(eq(dbSchema.boardClimbs.uuid, group.entityId)) + .limit(1); + if (climb) { + group.climbUuid = group.entityId; + group.climbName = climb.name ?? undefined; + group.boardType = climb.boardType; + group.setterUsername = climb.setterUsername ?? undefined; + } + } else if (climbTypes.includes(group.type)) { const [climb] = await db .select({ name: dbSchema.boardClimbs.name, boardType: dbSchema.boardClimbs.boardType }) .from(dbSchema.boardClimbs) diff --git a/packages/backend/src/graphql/resolvers/social/setter-follows.ts b/packages/backend/src/graphql/resolvers/social/setter-follows.ts new file mode 100644 index 00000000..adfb1614 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/social/setter-follows.ts @@ -0,0 +1,462 @@ +import { eq, and, count, sql, ilike } from 'drizzle-orm'; +import type { ConnectionContext } from '@boardsesh/shared-schema'; +import { db } from '../../../db/client'; +import * as dbSchema from '@boardsesh/db/schema'; +import { requireAuthenticated, applyRateLimit, validateInput } from '../shared/helpers'; +import { + FollowSetterInputSchema, + SetterProfileInputSchema, + SetterClimbsInputSchema, + SearchUsersInputSchema, +} from '../../../validation/schemas'; +import { publishSocialEvent } from '../../../events/index'; + +export const setterFollowQueries = { + /** + * Get a setter profile by username + */ + setterProfile: async ( + _: unknown, + { input }: { input: { username: string } }, + ctx: ConnectionContext + ) => { + const validatedInput = validateInput(SetterProfileInputSchema, input, 'input'); + const username = validatedInput.username; + + // Get distinct board types and climb count + const boardTypeResults = await db + .select({ + boardType: dbSchema.boardClimbs.boardType, + climbCount: count(), + }) + .from(dbSchema.boardClimbs) + .where(eq(dbSchema.boardClimbs.setterUsername, username)) + .groupBy(dbSchema.boardClimbs.boardType); + + if (boardTypeResults.length === 0) { + return null; + } + + const boardTypes = boardTypeResults.map((r) => r.boardType); + const totalClimbCount = boardTypeResults.reduce((sum, r) => sum + Number(r.climbCount), 0); + + // Count followers + const [followerResult] = await db + .select({ count: count() }) + .from(dbSchema.setterFollows) + .where(eq(dbSchema.setterFollows.setterUsername, username)); + + const followerCount = Number(followerResult?.count ?? 0); + + // Check isFollowedByMe + let isFollowedByMe = false; + if (ctx.isAuthenticated && ctx.userId) { + const [followCheck] = await db + .select({ count: count() }) + .from(dbSchema.setterFollows) + .where( + and( + eq(dbSchema.setterFollows.followerId, ctx.userId), + eq(dbSchema.setterFollows.setterUsername, username) + ) + ); + isFollowedByMe = Number(followCheck?.count ?? 0) > 0; + } + + // Check for linked Boardsesh user + const linkedUsers = await db + .select({ + userId: dbSchema.userBoardMappings.userId, + displayName: dbSchema.userProfiles.displayName, + avatarUrl: dbSchema.userProfiles.avatarUrl, + userName: dbSchema.users.name, + userImage: dbSchema.users.image, + }) + .from(dbSchema.userBoardMappings) + .innerJoin(dbSchema.users, eq(dbSchema.userBoardMappings.userId, dbSchema.users.id)) + .leftJoin(dbSchema.userProfiles, eq(dbSchema.userBoardMappings.userId, dbSchema.userProfiles.userId)) + .where(eq(dbSchema.userBoardMappings.boardUsername, username)) + .limit(1); + + const linkedUser = linkedUsers[0]; + + return { + username, + climbCount: totalClimbCount, + boardTypes, + followerCount, + isFollowedByMe, + linkedUserId: linkedUser?.userId ?? null, + linkedUserDisplayName: linkedUser?.displayName || linkedUser?.userName || null, + linkedUserAvatarUrl: linkedUser?.avatarUrl || linkedUser?.userImage || null, + }; + }, + + /** + * Get climbs created by a setter + */ + setterClimbs: async ( + _: unknown, + { input }: { input: { username: string; boardType?: string; limit?: number; offset?: number } }, + _ctx: ConnectionContext + ) => { + const validatedInput = validateInput(SetterClimbsInputSchema, input, 'input'); + const { username, boardType, limit = 20, offset = 0 } = validatedInput; + + // Build conditions + const conditions = [eq(dbSchema.boardClimbs.setterUsername, username)]; + if (boardType) { + conditions.push(eq(dbSchema.boardClimbs.boardType, boardType)); + } + + // Get total count + const [countResult] = await db + .select({ count: count() }) + .from(dbSchema.boardClimbs) + .where(and(...conditions)); + + const totalCount = Number(countResult?.count ?? 0); + + // Get climbs with stats + const climbs = await db + .select({ + uuid: dbSchema.boardClimbs.uuid, + name: dbSchema.boardClimbs.name, + boardType: dbSchema.boardClimbs.boardType, + layoutId: dbSchema.boardClimbs.layoutId, + angle: dbSchema.boardClimbs.angle, + createdAt: dbSchema.boardClimbs.createdAt, + qualityAverage: dbSchema.boardClimbStats.qualityAverage, + ascensionistCount: dbSchema.boardClimbStats.ascensionistCount, + difficultyName: dbSchema.boardDifficultyGrades.boulderName, + }) + .from(dbSchema.boardClimbs) + .leftJoin( + dbSchema.boardClimbStats, + and( + eq(dbSchema.boardClimbStats.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardClimbStats.climbUuid, dbSchema.boardClimbs.uuid), + eq(dbSchema.boardClimbStats.angle, sql`COALESCE(${dbSchema.boardClimbs.angle}, 0)`), + ) + ) + .leftJoin( + dbSchema.boardDifficultyGrades, + and( + eq(dbSchema.boardDifficultyGrades.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardDifficultyGrades.difficulty, sql`CAST(${dbSchema.boardClimbStats.displayDifficulty} AS INTEGER)`), + ) + ) + .where(and(...conditions)) + .orderBy(sql`${dbSchema.boardClimbs.createdAt} DESC NULLS LAST`) + .limit(limit) + .offset(offset); + + return { + climbs: climbs.map((c) => ({ + uuid: c.uuid, + name: c.name, + boardType: c.boardType, + layoutId: c.layoutId, + angle: c.angle, + difficultyName: c.difficultyName ?? null, + qualityAverage: c.qualityAverage ?? null, + ascensionistCount: c.ascensionistCount ?? null, + createdAt: c.createdAt ?? null, + })), + totalCount, + hasMore: offset + climbs.length < totalCount, + }; + }, + + /** + * Unified search for users and setters + */ + searchUsersAndSetters: async ( + _: unknown, + { input }: { input: { query: string; boardType?: string; limit?: number; offset?: number } }, + ctx: ConnectionContext + ) => { + await applyRateLimit(ctx, 20); + + const validatedInput = validateInput(SearchUsersInputSchema, input, 'input'); + const query = validatedInput.query; + const limit = validatedInput.limit ?? 20; + const offset = validatedInput.offset ?? 0; + + const escapedQuery = query.replace(/[%_\\]/g, '\\$&'); + const searchPattern = `%${escapedQuery}%`; + const prefixPattern = `${escapedQuery}%`; + + // 1. Search Boardsesh users (same as existing searchUsers) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const thirtyDaysAgoIso = thirtyDaysAgo.toISOString(); + + const userResults = await db + .select({ + id: dbSchema.users.id, + name: dbSchema.users.name, + image: dbSchema.users.image, + displayName: dbSchema.userProfiles.displayName, + avatarUrl: dbSchema.userProfiles.avatarUrl, + followerCount: sql`(select count(*)::int from user_follows where following_id = ${dbSchema.users.id})`, + followingCount: sql`(select count(*)::int from user_follows where follower_id = ${dbSchema.users.id})`, + recentAscentCount: sql`(select count(*)::int from boardsesh_ticks where user_id = ${dbSchema.users.id} and created_at > ${thirtyDaysAgoIso})`, + isFollowedByMe: (ctx.isAuthenticated && ctx.userId) + ? sql`exists(select 1 from user_follows where follower_id = ${ctx.userId} and following_id = ${dbSchema.users.id})` + : sql`false`, + }) + .from(dbSchema.users) + .leftJoin(dbSchema.userProfiles, eq(dbSchema.users.id, dbSchema.userProfiles.userId)) + .where( + sql`(${dbSchema.userProfiles.displayName} ILIKE ${searchPattern} OR ${dbSchema.users.name} ILIKE ${searchPattern})` + ) + .orderBy( + sql`case when ${dbSchema.userProfiles.displayName} ilike ${prefixPattern} or ${dbSchema.users.name} ilike ${prefixPattern} then 0 else 1 end`, + sql`(select count(*)::int from boardsesh_ticks where user_id = ${dbSchema.users.id} and created_at > ${thirtyDaysAgoIso}) DESC`, + ) + .limit(limit); + + // 2. Search setters from board_climbs + const setterResults = await db + .select({ + setterUsername: dbSchema.boardClimbs.setterUsername, + boardTypes: sql`array_agg(DISTINCT ${dbSchema.boardClimbs.boardType})`, + climbCount: sql`count(DISTINCT ${dbSchema.boardClimbs.uuid})::int`, + }) + .from(dbSchema.boardClimbs) + .where( + and( + ilike(dbSchema.boardClimbs.setterUsername, searchPattern), + sql`${dbSchema.boardClimbs.setterUsername} IS NOT NULL`, + ) + ) + .groupBy(dbSchema.boardClimbs.setterUsername) + .orderBy(sql`count(DISTINCT ${dbSchema.boardClimbs.uuid}) DESC`) + .limit(limit); + + // 3. Get linked usernames to de-duplicate + const linkedUsernames = new Set(); + if (userResults.length > 0) { + const userIds = userResults.map((r) => r.id); + const mappings = await db + .select({ + userId: dbSchema.userBoardMappings.userId, + boardUsername: dbSchema.userBoardMappings.boardUsername, + }) + .from(dbSchema.userBoardMappings) + .where(sql`${dbSchema.userBoardMappings.userId} IN (${sql.join(userIds.map(id => sql`${id}`), sql`, `)})`); + + for (const m of mappings) { + if (m.boardUsername) { + linkedUsernames.add(m.boardUsername); + } + } + } + + // 4. Check isFollowedByMe for setter results + let setterFollowedSet = new Set(); + if (ctx.isAuthenticated && ctx.userId && setterResults.length > 0) { + const setterUsernames = setterResults + .map((r) => r.setterUsername) + .filter((u): u is string => u !== null); + if (setterUsernames.length > 0) { + const followedSetters = await db + .select({ setterUsername: dbSchema.setterFollows.setterUsername }) + .from(dbSchema.setterFollows) + .where( + and( + eq(dbSchema.setterFollows.followerId, ctx.userId), + sql`${dbSchema.setterFollows.setterUsername} IN (${sql.join(setterUsernames.map(u => sql`${u}`), sql`, `)})` + ) + ); + setterFollowedSet = new Set(followedSetters.map((f) => f.setterUsername)); + } + } + + // 5. Build unified results + const results: Array<{ + user?: { + id: string; + displayName?: string; + avatarUrl?: string; + followerCount: number; + followingCount: number; + isFollowedByMe: boolean; + }; + setter?: { + username: string; + climbCount: number; + boardTypes: string[]; + isFollowedByMe: boolean; + }; + recentAscentCount: number; + matchReason?: string; + }> = []; + + // Add user results + for (const row of userResults) { + results.push({ + user: { + id: row.id, + displayName: row.displayName || row.name || undefined, + avatarUrl: row.avatarUrl || row.image || undefined, + followerCount: Number(row.followerCount ?? 0), + followingCount: Number(row.followingCount ?? 0), + isFollowedByMe: Boolean(row.isFollowedByMe), + }, + recentAscentCount: Number(row.recentAscentCount ?? 0), + matchReason: 'name match', + }); + } + + // Add setter results (de-duplicated) + for (const row of setterResults) { + if (!row.setterUsername || linkedUsernames.has(row.setterUsername)) { + continue; + } + results.push({ + setter: { + username: row.setterUsername, + climbCount: Number(row.climbCount), + boardTypes: row.boardTypes || [], + isFollowedByMe: setterFollowedSet.has(row.setterUsername), + }, + recentAscentCount: 0, + matchReason: 'setter match', + }); + } + + // Sort: users first (by ascent count), then setters (by climb count) + results.sort((a, b) => { + if (a.user && !b.user) return -1; + if (!a.user && b.user) return 1; + if (a.user && b.user) return b.recentAscentCount - a.recentAscentCount; + if (a.setter && b.setter) return b.setter.climbCount - a.setter.climbCount; + return 0; + }); + + const paginatedResults = results.slice(offset, offset + limit); + + return { + results: paginatedResults, + totalCount: results.length, + hasMore: offset + paginatedResults.length < results.length, + }; + }, +}; + +export const setterFollowMutations = { + /** + * Follow a setter (idempotent) + */ + followSetter: async ( + _: unknown, + { input }: { input: { setterUsername: string } }, + ctx: ConnectionContext + ): Promise => { + requireAuthenticated(ctx); + await applyRateLimit(ctx, 30, 'follow'); + + const validatedInput = validateInput(FollowSetterInputSchema, input, 'input'); + const myUserId = ctx.userId!; + const setterUsername = validatedInput.setterUsername; + + // Verify setter exists in board_climbs + const [exists] = await db + .select({ count: count() }) + .from(dbSchema.boardClimbs) + .where(eq(dbSchema.boardClimbs.setterUsername, setterUsername)) + .limit(1); + + if (Number(exists?.count ?? 0) === 0) { + throw new Error('Setter not found'); + } + + // Insert setter follow + const result = await db + .insert(dbSchema.setterFollows) + .values({ + followerId: myUserId, + setterUsername, + }) + .onConflictDoNothing() + .returning(); + + // Check if setter has a linked Boardsesh account + if (result.length > 0) { + const linkedUsers = await db + .select({ userId: dbSchema.userBoardMappings.userId }) + .from(dbSchema.userBoardMappings) + .where(eq(dbSchema.userBoardMappings.boardUsername, setterUsername)) + .limit(1); + + if (linkedUsers.length > 0 && linkedUsers[0].userId !== myUserId) { + // Also create user_follows entry + await db + .insert(dbSchema.userFollows) + .values({ + followerId: myUserId, + followingId: linkedUsers[0].userId, + }) + .onConflictDoNothing(); + } + + publishSocialEvent({ + type: 'follow.created', + actorId: myUserId, + entityType: 'user', + entityId: setterUsername, + timestamp: Date.now(), + metadata: { followedSetterUsername: setterUsername }, + }).catch((err) => console.error('[SetterFollows] Failed to publish social event:', err)); + } + + return true; + }, + + /** + * Unfollow a setter + */ + unfollowSetter: async ( + _: unknown, + { input }: { input: { setterUsername: string } }, + ctx: ConnectionContext + ): Promise => { + requireAuthenticated(ctx); + await applyRateLimit(ctx, 30, 'follow'); + + const validatedInput = validateInput(FollowSetterInputSchema, input, 'input'); + const myUserId = ctx.userId!; + const setterUsername = validatedInput.setterUsername; + + await db + .delete(dbSchema.setterFollows) + .where( + and( + eq(dbSchema.setterFollows.followerId, myUserId), + eq(dbSchema.setterFollows.setterUsername, setterUsername) + ) + ); + + // Also remove user_follows if linked + const linkedUsers = await db + .select({ userId: dbSchema.userBoardMappings.userId }) + .from(dbSchema.userBoardMappings) + .where(eq(dbSchema.userBoardMappings.boardUsername, setterUsername)) + .limit(1); + + if (linkedUsers.length > 0) { + await db + .delete(dbSchema.userFollows) + .where( + and( + eq(dbSchema.userFollows.followerId, myUserId), + eq(dbSchema.userFollows.followingId, linkedUsers[0].userId) + ) + ); + } + + return true; + }, +}; diff --git a/packages/backend/src/validation/schemas.ts b/packages/backend/src/validation/schemas.ts index c31ecf17..9f17a264 100644 --- a/packages/backend/src/validation/schemas.ts +++ b/packages/backend/src/validation/schemas.ts @@ -517,6 +517,30 @@ export const SearchUsersInputSchema = z.object({ offset: z.number().int().min(0).optional().default(0), }); +/** + * Follow setter input validation schema + */ +export const FollowSetterInputSchema = z.object({ + setterUsername: z.string().min(1, 'Setter username cannot be empty').max(100), +}); + +/** + * Setter profile input validation schema + */ +export const SetterProfileInputSchema = z.object({ + username: z.string().min(1, 'Username cannot be empty').max(100), +}); + +/** + * Setter climbs input validation schema + */ +export const SetterClimbsInputSchema = z.object({ + username: z.string().min(1, 'Username cannot be empty').max(100), + boardType: BoardNameSchema.optional(), + limit: z.number().int().min(1).max(100).optional().default(20), + offset: z.number().int().min(0).optional().default(0), +}); + /** * Following ascents feed input validation schema */ diff --git a/packages/db/drizzle/0057_volatile_miss_america.sql b/packages/db/drizzle/0057_volatile_miss_america.sql new file mode 100644 index 00000000..9f8f2173 --- /dev/null +++ b/packages/db/drizzle/0057_volatile_miss_america.sql @@ -0,0 +1,13 @@ +ALTER TYPE "public"."notification_type" ADD VALUE 'new_climbs_synced';--> statement-breakpoint +CREATE TABLE "setter_follows" ( + "id" bigserial PRIMARY KEY NOT NULL, + "follower_id" text NOT NULL, + "setter_username" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "setter_follows" ADD CONSTRAINT "setter_follows_follower_id_users_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "unique_setter_follow" ON "setter_follows" USING btree ("follower_id","setter_username");--> statement-breakpoint +CREATE INDEX "setter_follows_follower_idx" ON "setter_follows" USING btree ("follower_id");--> statement-breakpoint +CREATE INDEX "setter_follows_setter_idx" ON "setter_follows" USING btree ("setter_username");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "board_climbs_setter_username_idx" ON "board_climbs" ("setter_username") WHERE setter_username IS NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0057_snapshot.json b/packages/db/drizzle/meta/0057_snapshot.json new file mode 100644 index 00000000..0e59c2cd --- /dev/null +++ b/packages/db/drizzle/meta/0057_snapshot.json @@ -0,0 +1,7254 @@ +{ + "id": "540d267a-de7b-4a99-84cb-5634238dd5ee", + "prevId": "e1592adf-8242-469e-8441-97ac5e61529f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.board_attempts": { + "name": "board_attempts", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_attempts_board_type_id_pk": { + "name": "board_attempts_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_beta_links": { + "name": "board_beta_links", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreign_username": { + "name": "foreign_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_beta_links_board_type_climb_uuid_link_pk": { + "name": "board_beta_links_board_type_climb_uuid_link_pk", + "columns": [ + "board_type", + "climb_uuid", + "link" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_circuits": { + "name": "board_circuits", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_circuits_user_fk": { + "name": "board_circuits_user_fk", + "tableFrom": "board_circuits", + "tableTo": "board_users", + "columnsFrom": [ + "board_type", + "user_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_circuits_board_type_uuid_pk": { + "name": "board_circuits_board_type_uuid_pk", + "columns": [ + "board_type", + "uuid" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_circuits_climbs": { + "name": "board_circuits_climbs", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "circuit_uuid": { + "name": "circuit_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_circuits_climbs_circuit_fk": { + "name": "board_circuits_climbs_circuit_fk", + "tableFrom": "board_circuits_climbs", + "tableTo": "board_circuits", + "columnsFrom": [ + "board_type", + "circuit_uuid" + ], + "columnsTo": [ + "board_type", + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_circuits_climbs_climb_fk": { + "name": "board_circuits_climbs_climb_fk", + "tableFrom": "board_circuits_climbs", + "tableTo": "board_climbs", + "columnsFrom": [ + "climb_uuid" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_circuits_climbs_board_type_circuit_uuid_climb_uuid_pk": { + "name": "board_circuits_climbs_board_type_circuit_uuid_climb_uuid_pk", + "columns": [ + "board_type", + "circuit_uuid", + "climb_uuid" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_climb_holds": { + "name": "board_climb_holds", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hold_id": { + "name": "hold_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frame_number": { + "name": "frame_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hold_state": { + "name": "hold_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "board_climb_holds_search_idx": { + "name": "board_climb_holds_search_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hold_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hold_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_climb_holds_climb_fk": { + "name": "board_climb_holds_climb_fk", + "tableFrom": "board_climb_holds", + "tableTo": "board_climbs", + "columnsFrom": [ + "climb_uuid" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_climb_holds_board_type_climb_uuid_hold_id_pk": { + "name": "board_climb_holds_board_type_climb_uuid_hold_id_pk", + "columns": [ + "board_type", + "climb_uuid", + "hold_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_climb_stats": { + "name": "board_climb_stats", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_difficulty": { + "name": "display_difficulty", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "benchmark_difficulty": { + "name": "benchmark_difficulty", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "ascensionist_count": { + "name": "ascensionist_count", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "difficulty_average": { + "name": "difficulty_average", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "quality_average": { + "name": "quality_average", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "fa_username": { + "name": "fa_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fa_at": { + "name": "fa_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_climb_stats_board_type_climb_uuid_angle_pk": { + "name": "board_climb_stats_board_type_climb_uuid_angle_pk", + "columns": [ + "board_type", + "climb_uuid", + "angle" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_climb_stats_history": { + "name": "board_climb_stats_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_difficulty": { + "name": "display_difficulty", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "benchmark_difficulty": { + "name": "benchmark_difficulty", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "ascensionist_count": { + "name": "ascensionist_count", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "difficulty_average": { + "name": "difficulty_average", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "quality_average": { + "name": "quality_average", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "fa_username": { + "name": "fa_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fa_at": { + "name": "fa_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "board_climb_stats_history_lookup_idx": { + "name": "board_climb_stats_history_lookup_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "angle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_climbs": { + "name": "board_climbs", + "schema": "", + "columns": { + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "setter_id": { + "name": "setter_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "setter_username": { + "name": "setter_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "hsm": { + "name": "hsm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "edge_left": { + "name": "edge_left", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "edge_right": { + "name": "edge_right", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "edge_bottom": { + "name": "edge_bottom", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "edge_top": { + "name": "edge_top", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frames_count": { + "name": "frames_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "frames_pace": { + "name": "frames_pace", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "frames": { + "name": "frames", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "synced": { + "name": "synced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "board_climbs_board_type_idx": { + "name": "board_climbs_board_type_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_climbs_layout_filter_idx": { + "name": "board_climbs_layout_filter_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_listed", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_draft", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "frames_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_climbs_edges_idx": { + "name": "board_climbs_edges_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "edge_left", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "edge_right", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "edge_bottom", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "edge_top", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_climbs_user_id_users_id_fk": { + "name": "board_climbs_user_id_users_id_fk", + "tableFrom": "board_climbs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_difficulty_grades": { + "name": "board_difficulty_grades", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "boulder_name": { + "name": "boulder_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_name": { + "name": "route_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_difficulty_grades_board_type_difficulty_pk": { + "name": "board_difficulty_grades_board_type_difficulty_pk", + "columns": [ + "board_type", + "difficulty" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_holes": { + "name": "board_holes", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mirrored_hole_id": { + "name": "mirrored_hole_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mirror_group": { + "name": "mirror_group", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "board_holes_product_fk": { + "name": "board_holes_product_fk", + "tableFrom": "board_holes", + "tableTo": "board_products", + "columnsFrom": [ + "board_type", + "product_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_holes_board_type_id_pk": { + "name": "board_holes_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_layouts": { + "name": "board_layouts", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instagram_caption": { + "name": "instagram_caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_mirrored": { + "name": "is_mirrored", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_layouts_product_fk": { + "name": "board_layouts_product_fk", + "tableFrom": "board_layouts", + "tableTo": "board_products", + "columnsFrom": [ + "board_type", + "product_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_layouts_board_type_id_pk": { + "name": "board_layouts_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_leds": { + "name": "board_leds", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_size_id": { + "name": "product_size_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hole_id": { + "name": "hole_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_leds_product_size_fk": { + "name": "board_leds_product_size_fk", + "tableFrom": "board_leds", + "tableTo": "board_product_sizes", + "columnsFrom": [ + "board_type", + "product_size_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_leds_hole_fk": { + "name": "board_leds_hole_fk", + "tableFrom": "board_leds", + "tableTo": "board_holes", + "columnsFrom": [ + "board_type", + "hole_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_leds_board_type_id_pk": { + "name": "board_leds_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_placement_roles": { + "name": "board_placement_roles", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "led_color": { + "name": "led_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screen_color": { + "name": "screen_color", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_placement_roles_product_fk": { + "name": "board_placement_roles_product_fk", + "tableFrom": "board_placement_roles", + "tableTo": "board_products", + "columnsFrom": [ + "board_type", + "product_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_placement_roles_board_type_id_pk": { + "name": "board_placement_roles_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_placements": { + "name": "board_placements", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hole_id": { + "name": "hole_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "set_id": { + "name": "set_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "default_placement_role_id": { + "name": "default_placement_role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_placements_layout_fk": { + "name": "board_placements_layout_fk", + "tableFrom": "board_placements", + "tableTo": "board_layouts", + "columnsFrom": [ + "board_type", + "layout_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_placements_hole_fk": { + "name": "board_placements_hole_fk", + "tableFrom": "board_placements", + "tableTo": "board_holes", + "columnsFrom": [ + "board_type", + "hole_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_placements_set_fk": { + "name": "board_placements_set_fk", + "tableFrom": "board_placements", + "tableTo": "board_sets", + "columnsFrom": [ + "board_type", + "set_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_placements_role_fk": { + "name": "board_placements_role_fk", + "tableFrom": "board_placements", + "tableTo": "board_placement_roles", + "columnsFrom": [ + "board_type", + "default_placement_role_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_placements_board_type_id_pk": { + "name": "board_placements_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_product_sizes": { + "name": "board_product_sizes", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "edge_left": { + "name": "edge_left", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "edge_right": { + "name": "edge_right", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "edge_bottom": { + "name": "edge_bottom", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "edge_top": { + "name": "edge_top", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_product_sizes_product_fk": { + "name": "board_product_sizes_product_fk", + "tableFrom": "board_product_sizes", + "tableTo": "board_products", + "columnsFrom": [ + "board_type", + "product_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_product_sizes_board_type_id_pk": { + "name": "board_product_sizes_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_product_sizes_layouts_sets": { + "name": "board_product_sizes_layouts_sets", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_size_id": { + "name": "product_size_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "set_id": { + "name": "set_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_psls_product_size_fk": { + "name": "board_psls_product_size_fk", + "tableFrom": "board_product_sizes_layouts_sets", + "tableTo": "board_product_sizes", + "columnsFrom": [ + "board_type", + "product_size_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_psls_layout_fk": { + "name": "board_psls_layout_fk", + "tableFrom": "board_product_sizes_layouts_sets", + "tableTo": "board_layouts", + "columnsFrom": [ + "board_type", + "layout_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_psls_set_fk": { + "name": "board_psls_set_fk", + "tableFrom": "board_product_sizes_layouts_sets", + "tableTo": "board_sets", + "columnsFrom": [ + "board_type", + "set_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_product_sizes_layouts_sets_board_type_id_pk": { + "name": "board_product_sizes_layouts_sets_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_products": { + "name": "board_products", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_count_in_frame": { + "name": "min_count_in_frame", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_count_in_frame": { + "name": "max_count_in_frame", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_products_board_type_id_pk": { + "name": "board_products_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_sets": { + "name": "board_sets", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hsm": { + "name": "hsm", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_sets_board_type_id_pk": { + "name": "board_sets_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_shared_syncs": { + "name": "board_shared_syncs", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synchronized_at": { + "name": "last_synchronized_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_shared_syncs_board_type_table_name_pk": { + "name": "board_shared_syncs_board_type_table_name_pk", + "columns": [ + "board_type", + "table_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_tags": { + "name": "board_tags", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_uuid": { + "name": "entity_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_tags_board_type_entity_uuid_user_id_name_pk": { + "name": "board_tags_board_type_entity_uuid_user_id_name_pk", + "columns": [ + "board_type", + "entity_uuid", + "user_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_user_syncs": { + "name": "board_user_syncs", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synchronized_at": { + "name": "last_synchronized_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_user_syncs_user_fk": { + "name": "board_user_syncs_user_fk", + "tableFrom": "board_user_syncs", + "tableTo": "board_users", + "columnsFrom": [ + "board_type", + "user_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_user_syncs_board_type_user_id_table_name_pk": { + "name": "board_user_syncs_board_type_user_id_table_name_pk", + "columns": [ + "board_type", + "user_id", + "table_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_users": { + "name": "board_users", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "board_users_board_type_id_pk": { + "name": "board_users_board_type_id_pk", + "columns": [ + "board_type", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_walls": { + "name": "board_walls", + "schema": "", + "columns": { + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_adjustable": { + "name": "is_adjustable", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "product_size_id": { + "name": "product_size_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hsm": { + "name": "hsm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_walls_user_fk": { + "name": "board_walls_user_fk", + "tableFrom": "board_walls", + "tableTo": "board_users", + "columnsFrom": [ + "board_type", + "user_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "board_walls_product_fk": { + "name": "board_walls_product_fk", + "tableFrom": "board_walls", + "tableTo": "board_products", + "columnsFrom": [ + "board_type", + "product_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "board_walls_layout_fk": { + "name": "board_walls_layout_fk", + "tableFrom": "board_walls", + "tableTo": "board_layouts", + "columnsFrom": [ + "board_type", + "layout_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "board_walls_product_size_fk": { + "name": "board_walls_product_size_fk", + "tableFrom": "board_walls", + "tableTo": "board_product_sizes", + "columnsFrom": [ + "board_type", + "product_size_id" + ], + "columnsTo": [ + "board_type", + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "board_walls_board_type_uuid_pk": { + "name": "board_walls_board_type_uuid_pk", + "columns": [ + "board_type", + "uuid" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_userId_users_id_fk": { + "name": "accounts_userId_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_providerAccountId_pk": { + "name": "accounts_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_userId_users_id_fk": { + "name": "sessions_userId_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationTokens": { + "name": "verificationTokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationTokens_identifier_token_pk": { + "name": "verificationTokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_credentials": { + "name": "user_credentials", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credentials_user_id_users_id_fk": { + "name": "user_credentials_user_id_users_id_fk", + "tableFrom": "user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instagram_url": { + "name": "instagram_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_user_id_users_id_fk": { + "name": "user_profiles_user_id_users_id_fk", + "tableFrom": "user_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.aurora_credentials": { + "name": "aurora_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aurora_user_id": { + "name": "aurora_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aurora_token": { + "name": "aurora_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_board_credential": { + "name": "unique_user_board_credential", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "aurora_credentials_user_idx": { + "name": "aurora_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "aurora_credentials_user_id_users_id_fk": { + "name": "aurora_credentials_user_id_users_id_fk", + "tableFrom": "aurora_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_board_mappings": { + "name": "user_board_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_user_id": { + "name": "board_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "board_username": { + "name": "board_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_board_mapping": { + "name": "unique_user_board_mapping", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_user_mapping_idx": { + "name": "board_user_mapping_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_board_mappings_user_id_users_id_fk": { + "name": "user_board_mappings_user_id_users_id_fk", + "tableFrom": "user_board_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gym_follows": { + "name": "gym_follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "gym_id": { + "name": "gym_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gym_follows_unique_gym_user": { + "name": "gym_follows_unique_gym_user", + "columns": [ + { + "expression": "gym_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gym_follows_gym_id_gyms_id_fk": { + "name": "gym_follows_gym_id_gyms_id_fk", + "tableFrom": "gym_follows", + "tableTo": "gyms", + "columnsFrom": [ + "gym_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gym_follows_user_id_users_id_fk": { + "name": "gym_follows_user_id_users_id_fk", + "tableFrom": "gym_follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gym_members": { + "name": "gym_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "gym_id": { + "name": "gym_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "gym_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gym_members_unique_gym_user": { + "name": "gym_members_unique_gym_user", + "columns": [ + { + "expression": "gym_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gym_members_gym_id_gyms_id_fk": { + "name": "gym_members_gym_id_gyms_id_fk", + "tableFrom": "gym_members", + "tableTo": "gyms", + "columnsFrom": [ + "gym_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gym_members_user_id_users_id_fk": { + "name": "gym_members_user_id_users_id_fk", + "tableFrom": "gym_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gyms": { + "name": "gyms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "gyms_unique_slug": { + "name": "gyms_unique_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"gyms\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "gyms_uuid_idx": { + "name": "gyms_uuid_idx", + "columns": [ + { + "expression": "uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gyms_owner_idx": { + "name": "gyms_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"gyms\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "gyms_public_idx": { + "name": "gyms_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"gyms\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gyms_owner_id_users_id_fk": { + "name": "gyms_owner_id_users_id_fk", + "tableFrom": "gyms", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "gyms_uuid_unique": { + "name": "gyms_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_follows": { + "name": "board_follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_uuid": { + "name": "board_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "board_follows_unique_user_board": { + "name": "board_follows_unique_user_board", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_follows_user_idx": { + "name": "board_follows_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_follows_board_uuid_idx": { + "name": "board_follows_board_uuid_idx", + "columns": [ + { + "expression": "board_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_follows_user_id_users_id_fk": { + "name": "board_follows_user_id_users_id_fk", + "tableFrom": "board_follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "board_follows_board_uuid_user_boards_uuid_fk": { + "name": "board_follows_board_uuid_user_boards_uuid_fk", + "tableFrom": "board_follows", + "tableTo": "user_boards", + "columnsFrom": [ + "board_uuid" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_boards": { + "name": "user_boards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "size_id": { + "name": "size_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "set_ids": { + "name": "set_ids", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_name": { + "name": "location_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_owned": { + "name": "is_owned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "angle": { + "name": "angle", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 40 + }, + "is_angle_adjustable": { + "name": "is_angle_adjustable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "gym_id": { + "name": "gym_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_boards_gym_idx": { + "name": "user_boards_gym_idx", + "columns": [ + { + "expression": "gym_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_boards_unique_owner_config": { + "name": "user_boards_unique_owner_config", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "size_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "set_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_boards\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_boards_owner_owned_idx": { + "name": "user_boards_owner_owned_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_owned", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_boards\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_boards_public_idx": { + "name": "user_boards_public_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_boards\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_boards_unique_slug": { + "name": "user_boards_unique_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_boards\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_boards_uuid_idx": { + "name": "user_boards_uuid_idx", + "columns": [ + { + "expression": "uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_boards_owner_id_users_id_fk": { + "name": "user_boards_owner_id_users_id_fk", + "tableFrom": "user_boards", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_boards_gym_id_gyms_id_fk": { + "name": "user_boards_gym_id_gyms_id_fk", + "tableFrom": "user_boards", + "tableTo": "gyms", + "columnsFrom": [ + "gym_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_boards_uuid_unique": { + "name": "user_boards_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_session_clients": { + "name": "board_session_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_leader": { + "name": "is_leader", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_session_clients_session_id_board_sessions_id_fk": { + "name": "board_session_clients_session_id_board_sessions_id_fk", + "tableFrom": "board_session_clients", + "tableTo": "board_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_session_queues": { + "name": "board_session_queues", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "current_climb_queue_item": { + "name": "current_climb_queue_item", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "board_session_queues_session_id_board_sessions_id_fk": { + "name": "board_session_queues_session_id_board_sessions_id_fk", + "tableFrom": "board_session_queues", + "tableTo": "board_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_sessions": { + "name": "board_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "board_path": { + "name": "board_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "discoverable": { + "name": "discoverable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "board_id": { + "name": "board_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_permanent": { + "name": "is_permanent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "board_sessions_location_idx": { + "name": "board_sessions_location_idx", + "columns": [ + { + "expression": "latitude", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "longitude", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_sessions_discoverable_idx": { + "name": "board_sessions_discoverable_idx", + "columns": [ + { + "expression": "discoverable", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_sessions_user_idx": { + "name": "board_sessions_user_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_sessions_status_idx": { + "name": "board_sessions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_sessions_last_activity_idx": { + "name": "board_sessions_last_activity_idx", + "columns": [ + { + "expression": "last_activity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_sessions_discovery_idx": { + "name": "board_sessions_discovery_idx", + "columns": [ + { + "expression": "discoverable", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_sessions_created_by_user_id_users_id_fk": { + "name": "board_sessions_created_by_user_id_users_id_fk", + "tableFrom": "board_sessions", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "board_sessions_board_id_user_boards_id_fk": { + "name": "board_sessions_board_id_user_boards_id_fk", + "tableFrom": "board_sessions", + "tableTo": "user_boards", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_boards": { + "name": "session_boards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "session_boards_session_board_idx": { + "name": "session_boards_session_board_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_boards_session_idx": { + "name": "session_boards_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_boards_board_idx": { + "name": "session_boards_board_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_boards_session_id_board_sessions_id_fk": { + "name": "session_boards_session_id_board_sessions_id_fk", + "tableFrom": "session_boards", + "tableTo": "board_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_boards_board_id_user_boards_id_fk": { + "name": "session_boards_board_id_user_boards_id_fk", + "tableFrom": "session_boards", + "tableTo": "user_boards", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_favorites": { + "name": "user_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_name": { + "name": "board_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_favorite": { + "name": "unique_user_favorite", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "angle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_favorites_user_idx": { + "name": "user_favorites_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_favorites_climb_idx": { + "name": "user_favorites_climb_idx", + "columns": [ + { + "expression": "board_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "angle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_favorites_user_id_users_id_fk": { + "name": "user_favorites_user_id_users_id_fk", + "tableFrom": "user_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardsesh_ticks": { + "name": "boardsesh_ticks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_mirror": { + "name": "is_mirror", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "status": { + "name": "status", + "type": "tick_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "quality": { + "name": "quality", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_benchmark": { + "name": "is_benchmark", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "climbed_at": { + "name": "climbed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "board_id": { + "name": "board_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "aurora_type": { + "name": "aurora_type", + "type": "aurora_table_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "aurora_id": { + "name": "aurora_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aurora_synced_at": { + "name": "aurora_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aurora_sync_error": { + "name": "aurora_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "boardsesh_ticks_user_board_idx": { + "name": "boardsesh_ticks_user_board_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boardsesh_ticks_climb_idx": { + "name": "boardsesh_ticks_climb_idx", + "columns": [ + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boardsesh_ticks_aurora_id_unique": { + "name": "boardsesh_ticks_aurora_id_unique", + "columns": [ + { + "expression": "aurora_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boardsesh_ticks_sync_pending_idx": { + "name": "boardsesh_ticks_sync_pending_idx", + "columns": [ + { + "expression": "aurora_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boardsesh_ticks_session_idx": { + "name": "boardsesh_ticks_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boardsesh_ticks_climbed_at_idx": { + "name": "boardsesh_ticks_climbed_at_idx", + "columns": [ + { + "expression": "climbed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boardsesh_ticks_board_climbed_at_idx": { + "name": "boardsesh_ticks_board_climbed_at_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "climbed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boardsesh_ticks_board_user_idx": { + "name": "boardsesh_ticks_board_user_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "boardsesh_ticks_user_id_users_id_fk": { + "name": "boardsesh_ticks_user_id_users_id_fk", + "tableFrom": "boardsesh_ticks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardsesh_ticks_session_id_board_sessions_id_fk": { + "name": "boardsesh_ticks_session_id_board_sessions_id_fk", + "tableFrom": "boardsesh_ticks", + "tableTo": "board_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "boardsesh_ticks_board_id_user_boards_id_fk": { + "name": "boardsesh_ticks_board_id_user_boards_id_fk", + "tableFrom": "boardsesh_ticks", + "tableTo": "user_boards", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "boardsesh_ticks_uuid_unique": { + "name": "boardsesh_ticks_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_climbs": { + "name": "playlist_climbs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_playlist_climb": { + "name": "unique_playlist_climb", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlist_climbs_climb_idx": { + "name": "playlist_climbs_climb_idx", + "columns": [ + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlist_climbs_position_idx": { + "name": "playlist_climbs_position_idx", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlist_climbs_playlist_id_playlists_id_fk": { + "name": "playlist_climbs_playlist_id_playlists_id_fk", + "tableFrom": "playlist_climbs", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_ownership": { + "name": "playlist_ownership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_playlist_ownership": { + "name": "unique_playlist_ownership", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlist_ownership_user_idx": { + "name": "playlist_ownership_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlist_ownership_playlist_id_playlists_id_fk": { + "name": "playlist_ownership_playlist_id_playlists_id_fk", + "tableFrom": "playlist_ownership", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_ownership_user_id_users_id_fk": { + "name": "playlist_ownership_user_id_users_id_fk", + "tableFrom": "playlist_ownership", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlists": { + "name": "playlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aurora_type": { + "name": "aurora_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aurora_id": { + "name": "aurora_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aurora_synced_at": { + "name": "aurora_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "playlists_board_layout_idx": { + "name": "playlists_board_layout_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlists_uuid_idx": { + "name": "playlists_uuid_idx", + "columns": [ + { + "expression": "uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlists_updated_at_idx": { + "name": "playlists_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlists_last_accessed_at_idx": { + "name": "playlists_last_accessed_at_idx", + "columns": [ + { + "expression": "last_accessed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlists_aurora_id_idx": { + "name": "playlists_aurora_id_idx", + "columns": [ + { + "expression": "aurora_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "playlists_uuid_unique": { + "name": "playlists_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_hold_classifications": { + "name": "user_hold_classifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_id": { + "name": "size_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hold_id": { + "name": "hold_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hold_type": { + "name": "hold_type", + "type": "hold_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "hand_rating": { + "name": "hand_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "foot_rating": { + "name": "foot_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pull_direction": { + "name": "pull_direction", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_hold_classifications_user_board_idx": { + "name": "user_hold_classifications_user_board_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "size_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_hold_classifications_unique_idx": { + "name": "user_hold_classifications_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "size_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hold_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_hold_classifications_hold_idx": { + "name": "user_hold_classifications_hold_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hold_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_hold_classifications_user_id_users_id_fk": { + "name": "user_hold_classifications_user_id_users_id_fk", + "tableFrom": "user_hold_classifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.esp32_controllers": { + "name": "esp32_controllers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "board_name": { + "name": "board_name", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_id": { + "name": "size_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "set_ids": { + "name": "set_ids", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "authorized_session_id": { + "name": "authorized_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "esp32_controllers_user_idx": { + "name": "esp32_controllers_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp32_controllers_api_key_idx": { + "name": "esp32_controllers_api_key_idx", + "columns": [ + { + "expression": "api_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "esp32_controllers_session_idx": { + "name": "esp32_controllers_session_idx", + "columns": [ + { + "expression": "authorized_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "esp32_controllers_user_id_users_id_fk": { + "name": "esp32_controllers_user_id_users_id_fk", + "tableFrom": "esp32_controllers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "esp32_controllers_api_key_unique": { + "name": "esp32_controllers_api_key_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setter_follows": { + "name": "setter_follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setter_username": { + "name": "setter_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_setter_follow": { + "name": "unique_setter_follow", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "setter_username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setter_follows_follower_idx": { + "name": "setter_follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setter_follows_setter_idx": { + "name": "setter_follows_setter_idx", + "columns": [ + { + "expression": "setter_username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "setter_follows_follower_id_users_id_fk": { + "name": "setter_follows_follower_id_users_id_fk", + "tableFrom": "setter_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_user_follow": { + "name": "unique_user_follow", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_follows_follower_idx": { + "name": "user_follows_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_follows_following_idx": { + "name": "user_follows_following_idx", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "no_self_follow": { + "name": "no_self_follow", + "value": "\"user_follows\".\"follower_id\" != \"user_follows\".\"following_id\"" + } + }, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "social_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "comments_entity_created_at_idx": { + "name": "comments_entity_created_at_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_user_created_at_idx": { + "name": "comments_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_comment_idx": { + "name": "comments_parent_comment_idx", + "columns": [ + { + "expression": "parent_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comments_uuid_unique": { + "name": "comments_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.votes": { + "name": "votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "social_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "votes_unique_user_entity": { + "name": "votes_unique_user_entity", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_entity_idx": { + "name": "votes_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_user_idx": { + "name": "votes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "votes_user_id_users_id_fk": { + "name": "votes_user_id_users_id_fk", + "tableFrom": "votes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "vote_value_check": { + "name": "vote_value_check", + "value": "\"votes\".\"value\" IN (1, -1)" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "social_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment_id": { + "name": "comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_recipient_unread_idx": { + "name": "notifications_recipient_unread_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_created_at_idx": { + "name": "notifications_recipient_created_at_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_dedup_idx": { + "name": "notifications_dedup_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_recipient_id_users_id_fk": { + "name": "notifications_recipient_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_actor_id_users_id_fk": { + "name": "notifications_actor_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "notifications_comment_id_comments_id_fk": { + "name": "notifications_comment_id_comments_id_fk", + "tableFrom": "notifications", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notifications_uuid_unique": { + "name": "notifications_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "social_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_uuid": { + "name": "board_uuid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_recipient_created_at_idx": { + "name": "feed_items_recipient_created_at_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_items_recipient_board_created_at_idx": { + "name": "feed_items_recipient_board_created_at_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_items_actor_created_at_idx": { + "name": "feed_items_actor_created_at_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_items_created_at_idx": { + "name": "feed_items_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_recipient_id_users_id_fk": { + "name": "feed_items_recipient_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.climb_classic_status": { + "name": "climb_classic_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_classic": { + "name": "is_classic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_proposal_id": { + "name": "last_proposal_id", + "type": "bigserial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "climb_classic_status_unique_idx": { + "name": "climb_classic_status_unique_idx", + "columns": [ + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "climb_classic_status_last_proposal_id_climb_proposals_id_fk": { + "name": "climb_classic_status_last_proposal_id_climb_proposals_id_fk", + "tableFrom": "climb_classic_status", + "tableTo": "climb_proposals", + "columnsFrom": [ + "last_proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.climb_community_status": { + "name": "climb_community_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "community_grade": { + "name": "community_grade", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_benchmark": { + "name": "is_benchmark", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_proposal_id": { + "name": "last_proposal_id", + "type": "bigserial", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "climb_community_status_unique_idx": { + "name": "climb_community_status_unique_idx", + "columns": [ + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "angle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "climb_community_status_last_proposal_id_climb_proposals_id_fk": { + "name": "climb_community_status_last_proposal_id_climb_proposals_id_fk", + "tableFrom": "climb_community_status", + "tableTo": "climb_proposals", + "columnsFrom": [ + "last_proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.climb_proposals": { + "name": "climb_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "climb_uuid": { + "name": "climb_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "angle": { + "name": "angle", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "proposer_id": { + "name": "proposer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "proposal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "proposed_value": { + "name": "proposed_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_value": { + "name": "current_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "proposal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "climb_proposals_climb_angle_type_idx": { + "name": "climb_proposals_climb_angle_type_idx", + "columns": [ + { + "expression": "climb_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "angle", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "climb_proposals_status_idx": { + "name": "climb_proposals_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "climb_proposals_proposer_idx": { + "name": "climb_proposals_proposer_idx", + "columns": [ + { + "expression": "proposer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "climb_proposals_board_type_idx": { + "name": "climb_proposals_board_type_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "climb_proposals_created_at_idx": { + "name": "climb_proposals_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "climb_proposals_proposer_id_users_id_fk": { + "name": "climb_proposals_proposer_id_users_id_fk", + "tableFrom": "climb_proposals", + "tableTo": "users", + "columnsFrom": [ + "proposer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "climb_proposals_resolved_by_users_id_fk": { + "name": "climb_proposals_resolved_by_users_id_fk", + "tableFrom": "climb_proposals", + "tableTo": "users", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "climb_proposals_uuid_unique": { + "name": "climb_proposals_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_roles": { + "name": "community_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "community_role_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_roles_board_type_idx": { + "name": "community_roles_board_type_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "community_roles_user_id_users_id_fk": { + "name": "community_roles_user_id_users_id_fk", + "tableFrom": "community_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "community_roles_granted_by_users_id_fk": { + "name": "community_roles_granted_by_users_id_fk", + "tableFrom": "community_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "community_roles_user_role_board_idx": { + "name": "community_roles_user_role_board_idx", + "nullsNotDistinct": true, + "columns": [ + "user_id", + "role", + "board_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_settings": { + "name": "community_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_settings_scope_key_idx": { + "name": "community_settings_scope_key_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "community_settings_set_by_users_id_fk": { + "name": "community_settings_set_by_users_id_fk", + "tableFrom": "community_settings", + "tableTo": "users", + "columnsFrom": [ + "set_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_votes": { + "name": "proposal_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proposal_votes_unique_user_proposal": { + "name": "proposal_votes_unique_user_proposal", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proposal_votes_proposal_idx": { + "name": "proposal_votes_proposal_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposal_votes_proposal_id_climb_proposals_id_fk": { + "name": "proposal_votes_proposal_id_climb_proposals_id_fk", + "tableFrom": "proposal_votes", + "tableTo": "climb_proposals", + "columnsFrom": [ + "proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_votes_user_id_users_id_fk": { + "name": "proposal_votes_user_id_users_id_fk", + "tableFrom": "proposal_votes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proposal_vote_value_check": { + "name": "proposal_vote_value_check", + "value": "\"proposal_votes\".\"value\" IN (1, -1)" + } + }, + "isRLSEnabled": false + }, + "public.new_climb_subscriptions": { + "name": "new_climb_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "board_type": { + "name": "board_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "new_climb_subscriptions_unique_user_board_layout": { + "name": "new_climb_subscriptions_unique_user_board_layout", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "new_climb_subscriptions_user_idx": { + "name": "new_climb_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "new_climb_subscriptions_board_layout_idx": { + "name": "new_climb_subscriptions_board_layout_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "new_climb_subscriptions_user_id_users_id_fk": { + "name": "new_climb_subscriptions_user_id_users_id_fk", + "tableFrom": "new_climb_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vote_counts": { + "name": "vote_counts", + "schema": "", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "social_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upvotes": { + "name": "upvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes": { + "name": "downvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hot_score": { + "name": "hot_score", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vote_counts_score_idx": { + "name": "vote_counts_score_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_counts_hot_score_idx": { + "name": "vote_counts_hot_score_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hot_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "vote_counts_entity_type_entity_id_pk": { + "name": "vote_counts_entity_type_entity_id_pk", + "columns": [ + "entity_type", + "entity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.gym_member_role": { + "name": "gym_member_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + }, + "public.aurora_table_type": { + "name": "aurora_table_type", + "schema": "public", + "values": [ + "ascents", + "bids" + ] + }, + "public.tick_status": { + "name": "tick_status", + "schema": "public", + "values": [ + "flash", + "send", + "attempt" + ] + }, + "public.hold_type": { + "name": "hold_type", + "schema": "public", + "values": [ + "jug", + "sloper", + "pinch", + "crimp", + "pocket" + ] + }, + "public.social_entity_type": { + "name": "social_entity_type", + "schema": "public", + "values": [ + "playlist_climb", + "climb", + "tick", + "comment", + "proposal", + "board", + "gym" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "new_follower", + "comment_reply", + "comment_on_tick", + "comment_on_climb", + "vote_on_tick", + "vote_on_comment", + "new_climb", + "new_climb_global", + "proposal_approved", + "proposal_rejected", + "proposal_vote", + "proposal_created", + "new_climbs_synced" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "ascent", + "new_climb", + "comment", + "proposal_approved", + "session_summary" + ] + }, + "public.community_role_type": { + "name": "community_role_type", + "schema": "public", + "values": [ + "admin", + "community_leader" + ] + }, + "public.proposal_status": { + "name": "proposal_status", + "schema": "public", + "values": [ + "open", + "approved", + "rejected", + "superseded" + ] + }, + "public.proposal_type": { + "name": "proposal_type", + "schema": "public", + "values": [ + "grade", + "classic", + "benchmark" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 3aff9b0a..4a7d82a4 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1770990200931, "tag": "0056_add-board-angle", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1771058771575, + "tag": "0057_volatile_miss_america", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/app/follows.ts b/packages/db/src/schema/app/follows.ts index 3a3b0792..4d71f3b0 100644 --- a/packages/db/src/schema/app/follows.ts +++ b/packages/db/src/schema/app/follows.ts @@ -16,3 +16,17 @@ export const userFollows = pgTable('user_follows', { export type UserFollow = typeof userFollows.$inferSelect; export type NewUserFollow = typeof userFollows.$inferInsert; + +export const setterFollows = pgTable('setter_follows', { + id: bigserial('id', { mode: 'number' }).primaryKey(), + followerId: text('follower_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + setterUsername: text('setter_username').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => ({ + uniqueFollow: uniqueIndex('unique_setter_follow').on(table.followerId, table.setterUsername), + followerIdx: index('setter_follows_follower_idx').on(table.followerId), + setterIdx: index('setter_follows_setter_idx').on(table.setterUsername), +})); + +export type SetterFollow = typeof setterFollows.$inferSelect; +export type NewSetterFollow = typeof setterFollows.$inferInsert; diff --git a/packages/db/src/schema/app/notifications.ts b/packages/db/src/schema/app/notifications.ts index 899a3d66..fcb8ccc8 100644 --- a/packages/db/src/schema/app/notifications.ts +++ b/packages/db/src/schema/app/notifications.ts @@ -23,6 +23,7 @@ export const notificationTypeEnum = pgEnum('notification_type', [ 'proposal_rejected', 'proposal_vote', 'proposal_created', + 'new_climbs_synced', ]); export const notifications = pgTable( @@ -81,4 +82,5 @@ export type NotificationType = | 'proposal_approved' | 'proposal_rejected' | 'proposal_vote' - | 'proposal_created'; + | 'proposal_created' + | 'new_climbs_synced'; diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 39a6844f..792063bf 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -1677,6 +1677,8 @@ export const typeDefs = /* GraphQL */ ` boardType: String "Proposal UUID (for deep-linking to a specific proposal)" proposalUuid: String + "Setter username (for new_climbs_synced notifications)" + setterUsername: String "Whether all notifications in the group are read" isRead: Boolean! "When the most recent notification was created" @@ -2035,6 +2037,138 @@ export const typeDefs = /* GraphQL */ ` hasMore: Boolean! } + # ============================================ + # Setter Profile & Search Types + # ============================================ + + """ + Profile of a climb setter (may or may not be a Boardsesh user). + """ + type SetterProfile { + "The setter's Aurora username" + username: String! + "Total number of climbs set across all boards" + climbCount: Int! + "Board types this setter has climbs on" + boardTypes: [String!]! + "Number of followers" + followerCount: Int! + "Whether the current user follows this setter" + isFollowedByMe: Boolean! + "Linked Boardsesh user ID (if setter has a Boardsesh account)" + linkedUserId: ID + "Linked user's display name" + linkedUserDisplayName: String + "Linked user's avatar URL" + linkedUserAvatarUrl: String + } + + """ + A setter result from unified search. + """ + type SetterSearchResult { + "The setter's Aurora username" + username: String! + "Total number of climbs set" + climbCount: Int! + "Board types this setter has climbs on" + boardTypes: [String!]! + "Whether the current user follows this setter" + isFollowedByMe: Boolean! + } + + """ + A unified search result (can be a Boardsesh user, a setter, or both). + """ + type UnifiedSearchResult { + "Boardsesh user profile (if result is a registered user)" + user: PublicUserProfile + "Setter profile (if result is a setter)" + setter: SetterSearchResult + "Number of recent ascents" + recentAscentCount: Int! + "Why this result matched the search" + matchReason: String + } + + """ + Paginated unified search results. + """ + type UnifiedSearchConnection { + "List of search results" + results: [UnifiedSearchResult!]! + "Total number of matching results" + totalCount: Int! + "Whether more results are available" + hasMore: Boolean! + } + + """ + A climb created by a setter, for display on profile pages. + """ + type SetterClimb { + "Climb UUID" + uuid: String! + "Climb name" + name: String + "Board type (kilter, tension, etc.)" + boardType: String! + "Layout ID" + layoutId: Int! + "Board angle in degrees" + angle: Int + "Display difficulty name (e.g. 'V5')" + difficultyName: String + "Average quality rating" + qualityAverage: Float + "Number of ascensionists" + ascensionistCount: Int + "When the climb was created" + createdAt: String + } + + """ + Paginated list of setter climbs. + """ + type SetterClimbsConnection { + "List of climbs" + climbs: [SetterClimb!]! + "Total number of climbs" + totalCount: Int! + "Whether more climbs are available" + hasMore: Boolean! + } + + """ + Input for following/unfollowing a setter. + """ + input FollowSetterInput { + "The setter's Aurora username" + setterUsername: String! + } + + """ + Input for getting a setter profile. + """ + input SetterProfileInput { + "The setter's Aurora username" + username: String! + } + + """ + Input for fetching setter climbs. + """ + input SetterClimbsInput { + "The setter's Aurora username" + username: String! + "Optional board type filter" + boardType: String + "Maximum number of climbs to return" + limit: Int + "Number of climbs to skip" + offset: Int + } + # ============================================ # Comments & Votes Types # ============================================ @@ -2690,6 +2824,22 @@ export const typeDefs = /* GraphQL */ ` """ searchUsers(input: SearchUsersInput!): UserSearchConnection! + """ + Search for users and setters by name. + Returns unified results with both Boardsesh users and climb setters. + """ + searchUsersAndSetters(input: SearchUsersInput!): UnifiedSearchConnection! + + """ + Get a setter profile by username. + """ + setterProfile(input: SetterProfileInput!): SetterProfile + + """ + Get climbs created by a setter. + """ + setterClimbs(input: SetterClimbsInput!): SetterClimbsConnection! + """ Get activity feed of ascents from followed users. Requires authentication. @@ -3058,6 +3208,16 @@ export const typeDefs = /* GraphQL */ ` """ unfollowUser(input: FollowInput!): Boolean! + """ + Follow a setter by username. Idempotent. + """ + followSetter(input: FollowSetterInput!): Boolean! + + """ + Unfollow a setter by username. + """ + unfollowSetter(input: FollowSetterInput!): Boolean! + """ Subscribe to new climbs for a board type and layout. """ diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 4e68ed93..9bea0515 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -586,6 +586,74 @@ export type UserSearchConnection = { hasMore: boolean; }; +// ============================================ +// Setter Profile & Search Types +// ============================================ + +export type SetterProfile = { + username: string; + climbCount: number; + boardTypes: string[]; + followerCount: number; + isFollowedByMe: boolean; + linkedUserId?: string | null; + linkedUserDisplayName?: string | null; + linkedUserAvatarUrl?: string | null; +}; + +export type SetterSearchResult = { + username: string; + climbCount: number; + boardTypes: string[]; + isFollowedByMe: boolean; +}; + +export type UnifiedSearchResult = { + user?: PublicUserProfile | null; + setter?: SetterSearchResult | null; + recentAscentCount: number; + matchReason?: string; +}; + +export type UnifiedSearchConnection = { + results: UnifiedSearchResult[]; + totalCount: number; + hasMore: boolean; +}; + +export type SetterClimb = { + uuid: string; + name?: string | null; + boardType: string; + layoutId: number; + angle?: number | null; + difficultyName?: string | null; + qualityAverage?: number | null; + ascensionistCount?: number | null; + createdAt?: string | null; +}; + +export type SetterClimbsConnection = { + climbs: SetterClimb[]; + totalCount: number; + hasMore: boolean; +}; + +export type FollowSetterInput = { + setterUsername: string; +}; + +export type SetterProfileInput = { + username: string; +}; + +export type SetterClimbsInput = { + username: string; + boardType?: string; + limit?: number; + offset?: number; +}; + export type FollowingAscentFeedItem = { uuid: string; userId: string; @@ -681,7 +749,8 @@ export type NotificationType = | 'proposal_approved' | 'proposal_rejected' | 'proposal_vote' - | 'proposal_created'; + | 'proposal_created' + | 'new_climbs_synced'; export type Notification = { uuid: string; @@ -725,6 +794,7 @@ export type GroupedNotification = { climbUuid?: string | null; boardType?: string | null; proposalUuid?: string | null; + setterUsername?: string | null; isRead: boolean; createdAt: string; }; 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 index fb9cd845..fc5cd8cc 100644 --- a/packages/web/app/api/internal/shared-sync/[board_name]/route.ts +++ b/packages/web/app/api/internal/shared-sync/[board_name]/route.ts @@ -1,8 +1,12 @@ // 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 { syncSharedData as syncSharedDataFunction, type NewClimbInfo } 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'; +import { getDb } from '@/app/lib/db/db'; +import { setterFollows, notifications, userBoardMappings, userFollows } from '@boardsesh/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import crypto from 'crypto'; export const dynamic = 'force-dynamic'; export const maxDuration = 300; @@ -13,29 +17,35 @@ type SharedSyncRouteParams = { board_name: string; }; +type MergedSyncResult = { + results: Record; + complete: boolean; + newClimbs: NewClimbInfo[]; +}; + const internalSyncSharedData = async ( board_name: AuroraBoardName, token: string, - previousResults: { results: Record; complete: boolean } = { + previousResults: MergedSyncResult = { results: {}, complete: false, + newClimbs: [], }, recursionCount = 0, -) => { +): Promise => { console.log(`Recursion count: ${recursionCount}`); if (recursionCount >= 100) { console.warn('Maximum recursion depth reached for shared sync'); - return { _complete: true, _maxRecursionReached: true, ...previousResults }; + return { ...previousResults, complete: true }; } 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 } = { + const mergedResults: MergedSyncResult = { results: {}, complete: false, + newClimbs: [...previousResults.newClimbs, ...currentResult.newClimbs], }; const categories = new Set([...Object.keys(previousResults.results), ...Object.keys(currentResult.results)]); @@ -63,6 +73,117 @@ const internalSyncSharedData = async ( return mergedResults; }; +/** + * Create batched notifications for setter followers when new climbs are synced. + */ +async function createSetterSyncNotifications( + boardName: AuroraBoardName, + newClimbs: NewClimbInfo[], +): Promise { + if (newClimbs.length === 0) return; + + try { + const db = getDb(); + + // Group new climbs by setter_username + const climbsBySetter = new Map(); + for (const climb of newClimbs) { + if (!climb.setterUsername) continue; + const existing = climbsBySetter.get(climb.setterUsername) ?? []; + existing.push(climb); + climbsBySetter.set(climb.setterUsername, existing); + } + + if (climbsBySetter.size === 0) return; + + const setterUsernames = Array.from(climbsBySetter.keys()); + + // Get all followers for these setters + const followers = await db + .select({ + followerId: setterFollows.followerId, + setterUsername: setterFollows.setterUsername, + }) + .from(setterFollows) + .where(inArray(setterFollows.setterUsername, setterUsernames)); + + if (followers.length === 0) return; + + // Also check user_follows for linked accounts + const linkedMappings = await db + .select({ + userId: userBoardMappings.userId, + boardUsername: userBoardMappings.boardUsername, + }) + .from(userBoardMappings) + .where(inArray(userBoardMappings.boardUsername, setterUsernames)); + + const linkedUsernameToUserId = new Map(); + for (const m of linkedMappings) { + if (m.boardUsername) { + linkedUsernameToUserId.set(m.boardUsername, m.userId); + } + } + + // Get user_follows for linked accounts + const linkedUserIds = Array.from(linkedUsernameToUserId.values()); + let userFollowsList: Array<{ followerId: string; followingId: string }> = []; + if (linkedUserIds.length > 0) { + userFollowsList = await db + .select({ + followerId: userFollows.followerId, + followingId: userFollows.followingId, + }) + .from(userFollows) + .where(inArray(userFollows.followingId, linkedUserIds)); + } + + // Build recipient set per setter + for (const [setterUsername, climbs] of climbsBySetter) { + const recipientIds = new Set(); + + // Add setter_follows followers + for (const f of followers) { + if (f.setterUsername === setterUsername) { + recipientIds.add(f.followerId); + } + } + + // Add user_follows followers for linked accounts + const linkedUserId = linkedUsernameToUserId.get(setterUsername); + if (linkedUserId) { + for (const f of userFollowsList) { + if (f.followingId === linkedUserId) { + recipientIds.add(f.followerId); + } + } + } + + if (recipientIds.size === 0) continue; + + // Create one notification per recipient per setter + const firstClimbUuid = climbs[0].uuid; + const notificationValues = Array.from(recipientIds).map((recipientId) => ({ + uuid: crypto.randomUUID(), + recipientId, + actorId: linkedUserId ?? null, + type: 'new_climbs_synced' as const, + entityType: 'climb' as const, + entityId: firstClimbUuid, + })); + + if (notificationValues.length > 0) { + await db.insert(notifications).values(notificationValues); + console.log( + `[SharedSync] Created ${notificationValues.length} notifications for setter "${setterUsername}" (${climbs.length} new climbs on ${boardName})`, + ); + } + } + } catch (error) { + console.error('[SharedSync] Failed to create setter sync notifications:', error); + } +} + export async function GET(request: Request, props: { params: Promise }) { const params = await props.params; try { @@ -97,10 +218,16 @@ export async function GET(request: Request, props: { params: Promise 0) { + await createSetterSyncNotifications(board_name, result.newClimbs); + } + return NextResponse.json({ success: true, - results: result, + results: { results: result.results, complete: result.complete }, complete: result.complete, + newClimbsCount: result.newClimbs.length, }); } catch (error) { console.error('Cron job failed:', error); diff --git a/packages/web/app/components/climb-list/setter-climb-list.tsx b/packages/web/app/components/climb-list/setter-climb-list.tsx new file mode 100644 index 00000000..cee6477c --- /dev/null +++ b/packages/web/app/components/climb-list/setter-climb-list.tsx @@ -0,0 +1,185 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import MuiButton from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import ToggleButton from '@mui/material/ToggleButton'; +import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; +import { + GET_SETTER_CLIMBS, + type GetSetterClimbsQueryVariables, + type GetSetterClimbsQueryResponse, +} from '@/app/lib/graphql/operations'; +import type { SetterClimb } from '@boardsesh/shared-schema'; + +interface SetterClimbListProps { + username: string; + boardTypes?: string[]; + authToken?: string | null; +} + +export default function SetterClimbList({ username, boardTypes, authToken }: SetterClimbListProps) { + const [climbs, setClimbs] = useState([]); + const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(false); + const [totalCount, setTotalCount] = useState(0); + const [selectedBoard, setSelectedBoard] = useState(undefined); + + const fetchClimbs = useCallback(async (offset = 0, boardType?: string) => { + setLoading(true); + try { + const client = createGraphQLHttpClient(authToken ?? null); + const response = await client.request( + GET_SETTER_CLIMBS, + { input: { username, boardType, limit: 20, offset } } + ); + + if (offset === 0) { + setClimbs(response.setterClimbs.climbs); + } else { + setClimbs((prev) => [...prev, ...response.setterClimbs.climbs]); + } + setHasMore(response.setterClimbs.hasMore); + setTotalCount(response.setterClimbs.totalCount); + } catch (error) { + console.error('Failed to fetch setter climbs:', error); + } finally { + setLoading(false); + } + }, [username, authToken]); + + useEffect(() => { + fetchClimbs(0, selectedBoard); + }, [fetchClimbs, selectedBoard]); + + const handleLoadMore = () => { + if (!loading && hasMore) { + fetchClimbs(climbs.length, selectedBoard); + } + }; + + const handleBoardChange = (_: React.MouseEvent, value: string | null) => { + setSelectedBoard(value ?? undefined); + }; + + const navigateToClimb = useCallback(async (climb: SetterClimb) => { + try { + const params = new URLSearchParams({ boardType: climb.boardType, climbUuid: climb.uuid }); + const res = await fetch(`/api/internal/climb-redirect?${params}`); + if (!res.ok) return; + const { url } = await res.json(); + if (url) window.location.href = url; + } catch { + // Silently fail navigation + } + }, []); + + return ( + + {/* Board type filter */} + {boardTypes && boardTypes.length > 1 && ( + + + All + {boardTypes.map((bt) => ( + + {bt.charAt(0).toUpperCase() + bt.slice(1)} + + ))} + + + )} + + {loading && climbs.length === 0 ? ( + + + + ) : climbs.length === 0 ? ( + + + No climbs found + + + ) : ( + <> + + {totalCount} climb{totalCount !== 1 ? 's' : ''} + + + {climbs.map((climb) => ( + navigateToClimb(climb)} + sx={{ + cursor: 'pointer', + '&:hover': { backgroundColor: 'action.hover' }, + borderBottom: '1px solid var(--neutral-200)', + py: 1.5, + px: 0, + }} + > + + + {climb.difficultyName && ( + + {climb.difficultyName} + + )} + {climb.angle != null && ( + + {climb.angle}° + + )} + {climb.ascensionistCount != null && climb.ascensionistCount > 0 && ( + + {climb.ascensionistCount} ascent{climb.ascensionistCount !== 1 ? 's' : ''} + + )} + {climb.qualityAverage != null && climb.qualityAverage > 0 && ( + + {'★'.repeat(Math.round(climb.qualityAverage))} + + )} + + } + /> + + ))} + + {hasMore && ( + + + {loading ? 'Loading...' : `Load more (${climbs.length} of ${totalCount})`} + + + )} + + )} + + ); +} diff --git a/packages/web/app/components/notifications/notification-item.tsx b/packages/web/app/components/notifications/notification-item.tsx index 9a9ef2a9..6b9c52cd 100644 --- a/packages/web/app/components/notifications/notification-item.tsx +++ b/packages/web/app/components/notifications/notification-item.tsx @@ -85,6 +85,10 @@ function getNotificationText(notification: GroupedNotification): string { case 'new_climb': case 'new_climb_global': return `${actorSummary} created a new climb`; + case 'new_climbs_synced': + return notification.setterUsername + ? `${notification.setterUsername} set new climbs` + : `${actorSummary} set new climbs`; default: return 'You have a new notification'; } @@ -108,6 +112,7 @@ function getNotificationIcon(type: NotificationType) { return ; case 'new_climb': case 'new_climb_global': + case 'new_climbs_synced': return ; default: return null; diff --git a/packages/web/app/components/notifications/notification-list.tsx b/packages/web/app/components/notifications/notification-list.tsx index 1b47b9a3..870dd14a 100644 --- a/packages/web/app/components/notifications/notification-list.tsx +++ b/packages/web/app/components/notifications/notification-list.tsx @@ -52,6 +52,8 @@ export default function NotificationList() { // Navigate based on notification type if (notification.type === 'new_follower' && notification.actors.length > 0) { router.push(`/profile/${notification.actors[0].id}`); + } else if (notification.type === 'new_climbs_synced' && notification.setterUsername) { + router.push(`/setter/${encodeURIComponent(notification.setterUsername)}`); } else if (notification.climbUuid && notification.boardType) { navigateToClimb(notification.boardType, notification.climbUuid, notification.proposalUuid); } diff --git a/packages/web/app/components/social/user-search-results.tsx b/packages/web/app/components/social/user-search-results.tsx index 7c506db2..8ed650fd 100644 --- a/packages/web/app/components/social/user-search-results.tsx +++ b/packages/web/app/components/social/user-search-results.tsx @@ -9,17 +9,23 @@ import ListItemText from '@mui/material/ListItemText'; import MuiAvatar from '@mui/material/Avatar'; import MuiButton from '@mui/material/Button'; import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import { PersonOutlined } from '@mui/icons-material'; import FollowButton from '@/app/components/ui/follow-button'; -import { FOLLOW_USER, UNFOLLOW_USER } from '@/app/lib/graphql/operations'; +import { + FOLLOW_USER, + UNFOLLOW_USER, + FOLLOW_SETTER, + UNFOLLOW_SETTER, +} from '@/app/lib/graphql/operations'; import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; import { - SEARCH_USERS, - type SearchUsersQueryVariables, - type SearchUsersQueryResponse, + SEARCH_USERS_AND_SETTERS, + type SearchUsersAndSettersQueryVariables, + type SearchUsersAndSettersQueryResponse, } from '@/app/lib/graphql/operations'; -import type { UserSearchResult } from '@boardsesh/shared-schema'; +import type { UnifiedSearchResult } from '@boardsesh/shared-schema'; interface UserSearchResultsProps { query: string; @@ -27,7 +33,7 @@ interface UserSearchResultsProps { } export default function UserSearchResults({ query, authToken }: UserSearchResultsProps) { - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(false); const [totalCount, setTotalCount] = useState(0); @@ -44,18 +50,18 @@ export default function UserSearchResults({ query, authToken }: UserSearchResult setLoading(true); try { const client = createGraphQLHttpClient(authToken); - const response = await client.request( - SEARCH_USERS, + const response = await client.request( + SEARCH_USERS_AND_SETTERS, { input: { query: searchQuery, limit: 20, offset } } ); if (offset === 0) { - setResults(response.searchUsers.results); + setResults(response.searchUsersAndSetters.results); } else { - setResults((prev) => [...prev, ...response.searchUsers.results]); + setResults((prev) => [...prev, ...response.searchUsersAndSetters.results]); } - setHasMore(response.searchUsers.hasMore); - setTotalCount(response.searchUsers.totalCount); + setHasMore(response.searchUsersAndSetters.hasMore); + setTotalCount(response.searchUsersAndSetters.totalCount); } catch (error) { console.error('Search failed:', error); } finally { @@ -119,7 +125,7 @@ export default function UserSearchResults({ query, authToken }: UserSearchResult return ( - No users found for "{query}" + No users or setters found for "{query}" ); @@ -128,42 +134,109 @@ export default function UserSearchResults({ query, authToken }: UserSearchResult return ( <> - {results.map((result) => ( - ({ input: { userId: id } })} - /> - } - > - - - {!result.user.avatarUrl && } - - - 0 - ? `${result.recentAscentCount} ascents this month` - : undefined - } - /> - - ))} + {results.map((result) => { + // User result (may also have setter info if linked) + if (result.user) { + return ( + ({ input: { userId: id } })} + /> + } + > + + + {!result.user.avatarUrl && } + + + + {result.recentAscentCount > 0 && ( + + {result.recentAscentCount} ascents this month + + )} + {result.setter && ( + + {result.setter.climbCount} climb{result.setter.climbCount !== 1 ? 's' : ''} set + + )} + + } + /> + + ); + } + + // Setter-only result (no linked Boardsesh user) + if (result.setter) { + return ( + ({ input: { setterUsername: id } })} + /> + } + > + + + + + + + + {result.setter.climbCount} climb{result.setter.climbCount !== 1 ? 's' : ''} + + {result.setter.boardTypes.map((bt) => ( + + ))} + + } + /> + + ); + } + + return null; + })} {hasMore && ( diff --git a/packages/web/app/crusher/[user_id]/profile-page-content.tsx b/packages/web/app/crusher/[user_id]/profile-page-content.tsx index d49541f8..9e9652ca 100644 --- a/packages/web/app/crusher/[user_id]/profile-page-content.tsx +++ b/packages/web/app/crusher/[user_id]/profile-page-content.tsx @@ -20,6 +20,7 @@ import Logo from '@/app/components/brand/logo'; import BackButton from '@/app/components/back-button'; import AscentsFeed from '@/app/components/activity-feed'; import FollowButton from '@/app/components/ui/follow-button'; +import SetterClimbList from '@/app/components/climb-list/setter-climb-list'; import FollowerCount from '@/app/components/social/follower-count'; import dayjs from 'dayjs'; import isoWeek from 'dayjs/plugin/isoWeek'; @@ -67,6 +68,10 @@ interface UserProfile { avatarUrl: string | null; instagramUrl: string | null; } | null; + credentials?: Array<{ + boardType: string; + auroraUsername: string; + }>; followerCount: number; followingCount: number; isFollowedByMe: boolean; @@ -231,6 +236,7 @@ export default function ProfilePageContent({ userId }: { userId: string }) { name: data.name, image: data.image, profile: data.profile, + credentials: data.credentials, followerCount: data.followerCount ?? 0, followingCount: data.followingCount ?? 0, isFollowedByMe: data.isFollowedByMe ?? false, @@ -650,6 +656,7 @@ 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 authToken = (session as { authToken?: string } | null)?.authToken ?? null; // Board options are now available for all users (no Aurora credentials required) const boardOptions = BOARD_TYPES.map((boardType) => ({ @@ -949,6 +956,31 @@ export default function ProfilePageContent({ userId }: { userId: string }) { + + {/* Created Climbs (from linked Aurora accounts) */} + {(() => { + const creds = profile?.credentials; + if (!creds || creds.length === 0) return null; + const uniqueSetters = Array.from( + new Map(creds.map((c) => [c.auroraUsername, c])).values() + ); + return uniqueSetters.map((cred) => ( + + + + Created Climbs + + + Climbs set by {cred.auroraUsername} on {cred.boardType.charAt(0).toUpperCase() + cred.boardType.slice(1)} + + + + + )); + })()} ); diff --git a/packages/web/app/lib/data-sync/aurora/shared-sync.ts b/packages/web/app/lib/data-sync/aurora/shared-sync.ts index 57ed4208..056ec153 100644 --- a/packages/web/app/lib/data-sync/aurora/shared-sync.ts +++ b/packages/web/app/lib/data-sync/aurora/shared-sync.ts @@ -1,13 +1,21 @@ import { getPool } from '@/app/lib/db/db'; import { SyncOptions, AuroraBoardName } from '../../api-wrappers/aurora/types'; import { sharedSync } from '../../api-wrappers/aurora/sharedSync'; -import { sql, eq } from 'drizzle-orm'; +import { sql, eq, inArray } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/neon-serverless'; import { NeonDatabase } from 'drizzle-orm/neon-serverless'; import { Attempt, BetaLink, Climb, ClimbStats, SharedSync, SyncPutFields } from '../../api-wrappers/sync-api-types'; import { UNIFIED_TABLES } from '../../db/queries/util/table-select'; import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; +export type NewClimbInfo = { + uuid: string; + setterId?: number; + setterUsername?: string; + layoutId: number; + name?: string; +}; + // Define shared sync tables in correct dependency order // Order matches what the Android app sends - keep full list to remain indistinguishable export const SHARED_SYNC_TABLES: string[] = [ @@ -139,10 +147,20 @@ async function upsertBetaLinks(db: NeonDatabase>, board: A ); } -async function upsertClimbs(db: NeonDatabase>, board: AuroraBoardName, data: Climb[]) { +async function upsertClimbs(db: NeonDatabase>, board: AuroraBoardName, data: Climb[]): Promise { const climbsSchema = UNIFIED_TABLES.climbs; const climbHoldsSchema = UNIFIED_TABLES.climbHolds; + if (data.length === 0) return []; + + // Check which UUIDs already exist to track new climbs + const uuids = data.map((c) => c.uuid); + const existingRows = await db + .select({ uuid: climbsSchema.uuid }) + .from(climbsSchema) + .where(inArray(climbsSchema.uuid, uuids)); + const existingUuids = new Set(existingRows.map((r) => r.uuid)); + await Promise.all( data.map(async (item: Climb) => { // Insert or update the climb @@ -210,6 +228,17 @@ async function upsertClimbs(db: NeonDatabase>, board: Auro await db.insert(climbHoldsSchema).values(holdsToInsert).onConflictDoNothing(); // Avoid duplicate inserts }), ); + + // Return info about newly inserted climbs + return data + .filter((c) => !existingUuids.has(c.uuid)) + .map((c) => ({ + uuid: c.uuid, + setterId: c.setter_id, + setterUsername: c.setter_username, + layoutId: c.layout_id, + name: c.name, + })); } async function upsertSharedTableData( @@ -217,27 +246,26 @@ async function upsertSharedTableData( boardName: AuroraBoardName, tableName: string, data: SyncPutFields[], -) { +): Promise { switch (tableName) { case 'attempts': await upsertAttempts(db, boardName, data as Attempt[]); - break; + return []; case 'climb_stats': await upsertClimbStats(db, boardName, data as ClimbStats[]); - break; + return []; case 'beta_links': await upsertBetaLinks(db, boardName, data as BetaLink[]); - break; + return []; case 'climbs': - await upsertClimbs(db, boardName, data as Climb[]); - break; + return await upsertClimbs(db, boardName, data as Climb[]); case 'shared_syncs': await updateSharedSyncs(db, boardName, data as SharedSync[]); - break; + return []; default: // Tables not in TABLES_TO_PROCESS are handled in the main sync loop console.log(`Table ${tableName} not handled in upsertSharedTableData`); - break; + return []; } } async function updateSharedSyncs( @@ -288,7 +316,7 @@ export async function getLastSharedSyncTimes(boardName: AuroraBoardName) { export async function syncSharedData( board: AuroraBoardName, token: string, -): Promise<{ complete: boolean; results: Record }> { +): Promise<{ complete: boolean; results: Record; newClimbs: NewClimbInfo[] }> { try { console.log('Entered sync shared data'); @@ -314,6 +342,7 @@ export async function syncSharedData( // Initialize results tracking const totalResults: Record = {}; + const allNewClimbs: NewClimbInfo[] = []; let isComplete = false; const syncResults = await sharedSync(board, syncParams, token); @@ -337,7 +366,8 @@ export async function syncSharedData( // 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); + const newClimbs = await upsertSharedTableData(tx, board, tableName, data); + allNewClimbs.push(...newClimbs); // Accumulate results if (!totalResults[tableName]) { @@ -409,7 +439,7 @@ export async function syncSharedData( }); console.log(`Sync complete: ${isComplete}`); - return { complete: isComplete, results: totalResults }; + return { complete: isComplete, results: totalResults, newClimbs: allNewClimbs }; } catch (error) { console.error('Error syncing shared data:', error); throw error; diff --git a/packages/web/app/lib/graphql/operations/notifications.ts b/packages/web/app/lib/graphql/operations/notifications.ts index 783dc815..d4f4eedc 100644 --- a/packages/web/app/lib/graphql/operations/notifications.ts +++ b/packages/web/app/lib/graphql/operations/notifications.ts @@ -57,6 +57,7 @@ export const GET_GROUPED_NOTIFICATIONS = gql` climbUuid boardType proposalUuid + setterUsername isRead createdAt } diff --git a/packages/web/app/lib/graphql/operations/social.ts b/packages/web/app/lib/graphql/operations/social.ts index 9ac0e865..8552916f 100644 --- a/packages/web/app/lib/graphql/operations/social.ts +++ b/packages/web/app/lib/graphql/operations/social.ts @@ -3,7 +3,10 @@ import type { PublicUserProfile, FollowConnection, UserSearchConnection, + UnifiedSearchConnection, FollowingAscentsFeedResult, + SetterProfile, + SetterClimbsConnection, } from '@boardsesh/shared-schema'; // ============================================ @@ -249,3 +252,133 @@ export interface GetGlobalAscentsFeedQueryVariables { export interface GetGlobalAscentsFeedQueryResponse { globalAscentsFeed: FollowingAscentsFeedResult; } + +// ============================================ +// Setter Follow Mutations +// ============================================ + +export const FOLLOW_SETTER = gql` + mutation FollowSetter($input: FollowSetterInput!) { + followSetter(input: $input) + } +`; + +export const UNFOLLOW_SETTER = gql` + mutation UnfollowSetter($input: FollowSetterInput!) { + unfollowSetter(input: $input) + } +`; + +// ============================================ +// Setter Queries +// ============================================ + +export const GET_SETTER_PROFILE = gql` + query GetSetterProfile($input: SetterProfileInput!) { + setterProfile(input: $input) { + username + climbCount + boardTypes + followerCount + isFollowedByMe + linkedUserId + linkedUserDisplayName + linkedUserAvatarUrl + } + } +`; + +export const GET_SETTER_CLIMBS = gql` + query GetSetterClimbs($input: SetterClimbsInput!) { + setterClimbs(input: $input) { + climbs { + uuid + name + boardType + layoutId + angle + difficultyName + qualityAverage + ascensionistCount + createdAt + } + totalCount + hasMore + } + } +`; + +// ============================================ +// Unified Search +// ============================================ + +export const SEARCH_USERS_AND_SETTERS = gql` + query SearchUsersAndSetters($input: SearchUsersInput!) { + searchUsersAndSetters(input: $input) { + results { + user { + id + displayName + avatarUrl + followerCount + followingCount + isFollowedByMe + } + setter { + username + climbCount + boardTypes + isFollowedByMe + } + recentAscentCount + matchReason + } + totalCount + hasMore + } + } +`; + +// ============================================ +// Setter Query/Mutation Variable Types +// ============================================ + +export interface FollowSetterMutationVariables { + input: { setterUsername: string }; +} + +export interface FollowSetterMutationResponse { + followSetter: boolean; +} + +export interface UnfollowSetterMutationVariables { + input: { setterUsername: string }; +} + +export interface UnfollowSetterMutationResponse { + unfollowSetter: boolean; +} + +export interface GetSetterProfileQueryVariables { + input: { username: string }; +} + +export interface GetSetterProfileQueryResponse { + setterProfile: SetterProfile | null; +} + +export interface GetSetterClimbsQueryVariables { + input: { username: string; boardType?: string; limit?: number; offset?: number }; +} + +export interface GetSetterClimbsQueryResponse { + setterClimbs: SetterClimbsConnection; +} + +export interface SearchUsersAndSettersQueryVariables { + input: { query: string; boardType?: string; limit?: number; offset?: number }; +} + +export interface SearchUsersAndSettersQueryResponse { + searchUsersAndSetters: UnifiedSearchConnection; +} diff --git a/packages/web/app/setter/[setter_username]/page.tsx b/packages/web/app/setter/[setter_username]/page.tsx new file mode 100644 index 00000000..de7a8e21 --- /dev/null +++ b/packages/web/app/setter/[setter_username]/page.tsx @@ -0,0 +1,10 @@ +import SetterProfileContent from './setter-profile-content'; + +export default async function SetterProfilePage({ + params, +}: { + params: Promise<{ setter_username: string }>; +}) { + const { setter_username } = await params; + return ; +} diff --git a/packages/web/app/setter/[setter_username]/setter-profile-content.tsx b/packages/web/app/setter/[setter_username]/setter-profile-content.tsx new file mode 100644 index 00000000..94f42026 --- /dev/null +++ b/packages/web/app/setter/[setter_username]/setter-profile-content.tsx @@ -0,0 +1,174 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import Box from '@mui/material/Box'; +import MuiAvatar from '@mui/material/Avatar'; +import MuiCard from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import MuiButton from '@mui/material/Button'; +import { PersonOutlined } from '@mui/icons-material'; +import { useSession } from 'next-auth/react'; +import Logo from '@/app/components/brand/logo'; +import BackButton from '@/app/components/back-button'; +import FollowButton from '@/app/components/ui/follow-button'; +import SetterClimbList from '@/app/components/climb-list/setter-climb-list'; +import { EmptyState } from '@/app/components/ui/empty-state'; +import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; +import { + GET_SETTER_PROFILE, + FOLLOW_SETTER, + UNFOLLOW_SETTER, + type GetSetterProfileQueryVariables, + type GetSetterProfileQueryResponse, +} from '@/app/lib/graphql/operations'; +import type { SetterProfile } from '@boardsesh/shared-schema'; +import styles from './setter-profile.module.css'; + +interface SetterProfileContentProps { + username: string; +} + +export default function SetterProfileContent({ username }: SetterProfileContentProps) { + const { data: session } = useSession(); + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(null); + + const fetchProfile = useCallback(async () => { + try { + const authToken = (session as { authToken?: string } | null)?.authToken ?? null; + const client = createGraphQLHttpClient(authToken); + const response = await client.request( + GET_SETTER_PROFILE, + { input: { username } } + ); + setProfile(response.setterProfile); + } catch (error) { + console.error('Failed to fetch setter profile:', error); + } finally { + setLoading(false); + } + }, [username, session]); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); + + if (loading) { + return ( + + + + + + ); + } + + if (!profile) { + return ( + + + + + + Setter Profile + + + + + + + ); + } + + const displayName = profile.linkedUserDisplayName || profile.username; + const avatarUrl = profile.linkedUserAvatarUrl; + const authToken = (session as { authToken?: string } | null)?.authToken ?? null; + + return ( + + + + + + Setter Profile + + + + + {/* Profile Card */} + + +
+ + {!avatarUrl && } + +
+ + + {displayName} + + ({ input: { setterUsername: id } })} + onFollowChange={(isFollowing) => { + if (profile) { + setProfile({ + ...profile, + followerCount: profile.followerCount + (isFollowing ? 1 : -1), + isFollowedByMe: isFollowing, + }); + } + }} + /> + + + {profile.followerCount} follower{profile.followerCount !== 1 ? 's' : ''} · {profile.climbCount} climb{profile.climbCount !== 1 ? 's' : ''} + +
+ {profile.boardTypes.map((bt) => ( + + ))} +
+ {profile.linkedUserId && ( + + View Boardsesh profile + + )} +
+
+
+
+ + {/* Created Climbs */} + + + + Created Climbs + + + + +
+
+ ); +} diff --git a/packages/web/app/setter/[setter_username]/setter-profile.module.css b/packages/web/app/setter/[setter_username]/setter-profile.module.css new file mode 100644 index 00000000..abb364ba --- /dev/null +++ b/packages/web/app/setter/[setter_username]/setter-profile.module.css @@ -0,0 +1,140 @@ +/* + * Setter profile page styles + * Uses CSS custom properties from index.css (derived from theme-config.ts) + */ + +.layout { + min-height: 100vh; + background: var(--background, #F9FAFB); +} + +.header { + background: var(--surface, #FFFFFF); + padding: 0 16px; + display: flex; + align-items: center; + gap: 16px; + box-shadow: var(--shadow-xs); + height: 64px; +} + +.headerTitle { + margin: 0 !important; + flex: 1; +} + +.content { + padding: 24px; + max-width: 800px; + margin: 0 auto; + width: 100%; +} + +.loadingContent { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.profileCard { + margin-bottom: 24px; +} + +.profileInfo { + display: flex; + align-items: center; + gap: 16px; +} + +.profileDetails { + display: flex; + flex-direction: column; +} + +.displayName { + margin: 0 !important; +} + +.boardBadges { + display: flex; + gap: 8px; + margin-top: 4px; + flex-wrap: wrap; +} + +.climbsCard { + margin-bottom: 24px; +} + +.filterRow { + margin-bottom: 16px; +} + +.climbList { + list-style: none; + padding: 0; + margin: 0; +} + +.climbItem { + display: flex; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--neutral-200); + gap: 12px; + text-decoration: none; + color: inherit; + cursor: pointer; +} + +.climbItem:last-child { + border-bottom: none; +} + +.climbItem:hover { + background-color: var(--neutral-50); +} + +.climbInfo { + flex: 1; + min-width: 0; +} + +.climbName { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.climbMeta { + display: flex; + gap: 12px; + align-items: center; + margin-top: 2px; +} + +@media (max-width: 768px) { + .content { + padding: 16px; + } + + .header { + padding: 0 12px; + gap: 8px; + } + + .profileInfo { + flex-direction: column; + text-align: center; + } + + .profileDetails { + align-items: center; + } + + .boardBadges { + justify-content: center; + } +} From 7f9b6f62fc622a95c678fb2cbc1233c11bc6569d Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sat, 14 Feb 2026 11:27:19 +0100 Subject: [PATCH 2/5] Add NEXTAUTH_SECRET to backend dev env so auth works locally The backend reads NEXTAUTH_SECRET to validate NextAuth JWTs but had no checked-in env file providing it. Creates .env.development (not gitignored) with the same dummy secret used by the web package, and updates the dev script to load it. Co-Authored-By: Claude Opus 4.6 --- packages/backend/.env.development | 9 +++++++++ packages/backend/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 packages/backend/.env.development diff --git a/packages/backend/.env.development b/packages/backend/.env.development new file mode 100644 index 00000000..591fb3fb --- /dev/null +++ b/packages/backend/.env.development @@ -0,0 +1,9 @@ +# Generic configuration for backend development +# This file is tracked in git and should NOT contain production secrets + +PORT=8080 +DATABASE_URL=postgresql://postgres:password@db.localtest.me:5432/main +REDIS_URL=redis://localhost:6379 + +# Must match the value in packages/web/.env.local so auth token validation works +NEXTAUTH_SECRET=68cJgCDE39gaXwi8LTVW4WioyhGxwcAd diff --git a/packages/backend/package.json b/packages/backend/package.json index d4f9fd0e..73721ff2 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "scripts": { - "dev": "NODE_ENV=development DOTENV_CONFIG_PATH=.env.local tsx watch src/index.ts", + "dev": "NODE_ENV=development DOTENV_CONFIG_PATH=.env.development tsx watch src/index.ts", "build": "tsc", "typecheck": "tsc --noEmit", "start": "tsx src/index.ts", From 65fcc05c4038ac23064d5d77a24a99dca264fb32 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sat, 14 Feb 2026 12:07:54 +0100 Subject: [PATCH 3/5] Redesign setter profile page with playlist-style hero card and multi-board climb thumbnails Add setterClimbsFull GraphQL query that returns full Climb data with litUpHoldsMap for thumbnail rendering. Extend ClimbsList with boardDetailsMap support for multi-board contexts. Redesign setter profile page to use shared playlist-view hero card layout with board filter slider showing all user boards (disabled for boards without setter climbs). Add unsupported visual state to ClimbCard/ClimbListItem for climbs from boards the user doesn't own. Co-Authored-By: Claude Opus 4.6 --- .../resolvers/social/setter-follows.ts | 250 ++++++++++++- packages/backend/src/validation/schemas.ts | 17 + packages/shared-schema/src/schema.ts | 37 ++ packages/shared-schema/src/types.ts | 3 + .../app/components/board-page/climbs-list.tsx | 24 +- .../board-scroll/board-scroll-card.tsx | 15 +- .../board-scroll/board-scroll.module.css | 10 + .../app/components/climb-card/climb-card.tsx | 9 +- .../components/climb-card/climb-list-item.tsx | 13 +- .../climb-list/setter-climb-list.tsx | 347 ++++++++++++------ .../web/app/lib/graphql/operations/social.ts | 59 ++- packages/web/app/lib/types.ts | 1 + .../web/app/setter/[setter_username]/page.tsx | 15 +- .../setter-profile-content.tsx | 192 +++++----- .../setter-profile.module.css | 140 ------- 15 files changed, 736 insertions(+), 396 deletions(-) delete mode 100644 packages/web/app/setter/[setter_username]/setter-profile.module.css diff --git a/packages/backend/src/graphql/resolvers/social/setter-follows.ts b/packages/backend/src/graphql/resolvers/social/setter-follows.ts index adfb1614..4457bdbc 100644 --- a/packages/backend/src/graphql/resolvers/social/setter-follows.ts +++ b/packages/backend/src/graphql/resolvers/social/setter-follows.ts @@ -1,5 +1,6 @@ import { eq, and, count, sql, ilike } from 'drizzle-orm'; -import type { ConnectionContext } from '@boardsesh/shared-schema'; +import type { ConnectionContext, Climb, BoardName } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; import { db } from '../../../db/client'; import * as dbSchema from '@boardsesh/db/schema'; import { requireAuthenticated, applyRateLimit, validateInput } from '../shared/helpers'; @@ -7,9 +8,13 @@ import { FollowSetterInputSchema, SetterProfileInputSchema, SetterClimbsInputSchema, + SetterClimbsFullInputSchema, SearchUsersInputSchema, } from '../../../validation/schemas'; import { publishSocialEvent } from '../../../events/index'; +import { getBoardTables, isValidBoardName } from '../../../db/queries/util/table-select'; +import { getSizeEdges } from '../../../db/queries/util/product-sizes-data'; +import { convertLitUpHoldsStringToMap } from '../../../db/queries/util/hold-state'; export const setterFollowQueries = { /** @@ -97,17 +102,20 @@ export const setterFollowQueries = { */ setterClimbs: async ( _: unknown, - { input }: { input: { username: string; boardType?: string; limit?: number; offset?: number } }, + { input }: { input: { username: string; boardType?: string; layoutId?: number; sortBy?: string; limit?: number; offset?: number } }, _ctx: ConnectionContext ) => { const validatedInput = validateInput(SetterClimbsInputSchema, input, 'input'); - const { username, boardType, limit = 20, offset = 0 } = validatedInput; + const { username, boardType, layoutId, sortBy = 'popular', limit = 20, offset = 0 } = validatedInput; // Build conditions const conditions = [eq(dbSchema.boardClimbs.setterUsername, username)]; if (boardType) { conditions.push(eq(dbSchema.boardClimbs.boardType, boardType)); } + if (layoutId != null) { + conditions.push(eq(dbSchema.boardClimbs.layoutId, layoutId)); + } // Get total count const [countResult] = await db @@ -117,15 +125,15 @@ export const setterFollowQueries = { const totalCount = Number(countResult?.count ?? 0); - // Get climbs with stats + // Get climbs with stats at the most popular angle (most ascensionists) const climbs = await db .select({ uuid: dbSchema.boardClimbs.uuid, name: dbSchema.boardClimbs.name, boardType: dbSchema.boardClimbs.boardType, layoutId: dbSchema.boardClimbs.layoutId, - angle: dbSchema.boardClimbs.angle, createdAt: dbSchema.boardClimbs.createdAt, + statsAngle: dbSchema.boardClimbStats.angle, qualityAverage: dbSchema.boardClimbStats.qualityAverage, ascensionistCount: dbSchema.boardClimbStats.ascensionistCount, difficultyName: dbSchema.boardDifficultyGrades.boulderName, @@ -136,7 +144,13 @@ export const setterFollowQueries = { and( eq(dbSchema.boardClimbStats.boardType, dbSchema.boardClimbs.boardType), eq(dbSchema.boardClimbStats.climbUuid, dbSchema.boardClimbs.uuid), - eq(dbSchema.boardClimbStats.angle, sql`COALESCE(${dbSchema.boardClimbs.angle}, 0)`), + eq(dbSchema.boardClimbStats.angle, sql`( + SELECT s.angle FROM board_climb_stats s + WHERE s.board_type = ${dbSchema.boardClimbs.boardType} + AND s.climb_uuid = ${dbSchema.boardClimbs.uuid} + ORDER BY s.ascensionist_count DESC NULLS LAST + LIMIT 1 + )`), ) ) .leftJoin( @@ -147,7 +161,11 @@ export const setterFollowQueries = { ) ) .where(and(...conditions)) - .orderBy(sql`${dbSchema.boardClimbs.createdAt} DESC NULLS LAST`) + .orderBy( + sortBy === 'popular' + ? sql`COALESCE(${dbSchema.boardClimbStats.ascensionistCount}, 0) DESC` + : sql`${dbSchema.boardClimbs.createdAt} DESC NULLS LAST` + ) .limit(limit) .offset(offset); @@ -157,7 +175,7 @@ export const setterFollowQueries = { name: c.name, boardType: c.boardType, layoutId: c.layoutId, - angle: c.angle, + angle: c.statsAngle ?? null, difficultyName: c.difficultyName ?? null, qualityAverage: c.qualityAverage ?? null, ascensionistCount: c.ascensionistCount ?? null, @@ -168,6 +186,222 @@ export const setterFollowQueries = { }; }, + /** + * Get climbs created by a setter with full Climb data (including litUpHoldsMap for thumbnails). + * Supports multi-board mode when boardType is omitted. + */ + setterClimbsFull: async ( + _: unknown, + { input }: { input: { + username: string; + boardType?: string; + layoutId?: number; + sizeId?: number; + setIds?: string; + angle?: number; + sortBy?: string; + limit?: number; + offset?: number; + } }, + _ctx: ConnectionContext + ): Promise<{ climbs: Climb[]; totalCount: number; hasMore: boolean }> => { + const validatedInput = validateInput(SetterClimbsFullInputSchema, input, 'input'); + const { username, boardType, sortBy = 'popular', limit = 20, offset = 0 } = validatedInput; + + if (boardType) { + // === Specific board mode === + const boardName = boardType as BoardName; + if (!isValidBoardName(boardName)) { + throw new Error(`Invalid board name: ${boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`); + } + + const angle = validatedInput.angle ?? 40; + const tables = getBoardTables(boardName); + + // Get total count + const [countResult] = await db + .select({ count: sql`count(*)::int` }) + .from(tables.climbs) + .where( + and( + eq(tables.climbs.setterUsername, username), + eq(tables.climbs.boardType, boardName) + ) + ); + + const totalCount = Number(countResult?.count ?? 0); + + // Get climbs with stats at specified angle + const results = await db + .select({ + uuid: tables.climbs.uuid, + layoutId: tables.climbs.layoutId, + setter_username: tables.climbs.setterUsername, + name: tables.climbs.name, + description: tables.climbs.description, + frames: tables.climbs.frames, + ascensionist_count: tables.climbStats.ascensionistCount, + difficulty: tables.difficultyGrades.boulderName, + quality_average: sql`ROUND(${tables.climbStats.qualityAverage}::numeric, 2)`, + difficulty_error: sql`ROUND(${tables.climbStats.difficultyAverage}::numeric - ${tables.climbStats.displayDifficulty}::numeric, 2)`, + benchmark_difficulty: tables.climbStats.benchmarkDifficulty, + }) + .from(tables.climbs) + .leftJoin( + tables.climbStats, + and( + eq(tables.climbStats.climbUuid, tables.climbs.uuid), + eq(tables.climbStats.boardType, boardName), + eq(tables.climbStats.angle, angle) + ) + ) + .leftJoin( + tables.difficultyGrades, + and( + eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`), + eq(tables.difficultyGrades.boardType, boardName) + ) + ) + .where( + and( + eq(tables.climbs.setterUsername, username), + eq(tables.climbs.boardType, boardName) + ) + ) + .orderBy( + sortBy === 'popular' + ? sql`COALESCE(${tables.climbStats.ascensionistCount}, 0) DESC` + : sql`${tables.climbs.createdAt} DESC NULLS LAST` + ) + .limit(limit + 1) + .offset(offset); + + const hasMore = results.length > limit; + const trimmedResults = hasMore ? results.slice(0, limit) : results; + + const climbs: Climb[] = trimmedResults.map((result) => ({ + uuid: result.uuid, + layoutId: result.layoutId, + setter_username: result.setter_username || '', + name: result.name || '', + description: result.description || '', + frames: result.frames || '', + angle, + ascensionist_count: Number(result.ascensionist_count || 0), + difficulty: result.difficulty || '', + quality_average: result.quality_average?.toString() || '0', + stars: Math.round((Number(result.quality_average) || 0) * 5), + difficulty_error: result.difficulty_error?.toString() || '0', + benchmark_difficulty: result.benchmark_difficulty && result.benchmark_difficulty > 0 ? result.benchmark_difficulty.toString() : null, + litUpHoldsMap: convertLitUpHoldsStringToMap(result.frames || '', boardName)[0], + boardType: boardName, + })); + + return { climbs, totalCount, hasMore }; + } else { + // === All boards mode === + // Get distinct board types for this setter + const boardTypeResults = await db + .select({ + boardType: dbSchema.boardClimbs.boardType, + }) + .from(dbSchema.boardClimbs) + .where(eq(dbSchema.boardClimbs.setterUsername, username)) + .groupBy(dbSchema.boardClimbs.boardType); + + const setterBoardTypes = boardTypeResults + .map((r) => r.boardType) + .filter((bt): bt is string => bt !== null && isValidBoardName(bt)); + + if (setterBoardTypes.length === 0) { + return { climbs: [], totalCount: 0, hasMore: false }; + } + + // Get total count across all boards + const [countResult] = await db + .select({ count: sql`count(*)::int` }) + .from(dbSchema.boardClimbs) + .where(eq(dbSchema.boardClimbs.setterUsername, username)); + + const totalCount = Number(countResult?.count ?? 0); + + // Query climbs across all board types using most popular angle + const tables = getBoardTables('kilter'); // All unified - just need the table refs + const results = await db + .select({ + uuid: tables.climbs.uuid, + layoutId: tables.climbs.layoutId, + boardType: tables.climbs.boardType, + setter_username: tables.climbs.setterUsername, + name: tables.climbs.name, + description: tables.climbs.description, + frames: tables.climbs.frames, + statsAngle: tables.climbStats.angle, + ascensionist_count: tables.climbStats.ascensionistCount, + difficulty: tables.difficultyGrades.boulderName, + quality_average: sql`ROUND(${tables.climbStats.qualityAverage}::numeric, 2)`, + difficulty_error: sql`ROUND(${tables.climbStats.difficultyAverage}::numeric - ${tables.climbStats.displayDifficulty}::numeric, 2)`, + benchmark_difficulty: tables.climbStats.benchmarkDifficulty, + }) + .from(tables.climbs) + .leftJoin( + tables.climbStats, + and( + eq(tables.climbStats.boardType, tables.climbs.boardType), + eq(tables.climbStats.climbUuid, tables.climbs.uuid), + eq(tables.climbStats.angle, sql`( + SELECT s.angle FROM board_climb_stats s + WHERE s.board_type = ${tables.climbs.boardType} + AND s.climb_uuid = ${tables.climbs.uuid} + ORDER BY s.ascensionist_count DESC NULLS LAST + LIMIT 1 + )`), + ) + ) + .leftJoin( + tables.difficultyGrades, + and( + eq(tables.difficultyGrades.boardType, tables.climbs.boardType), + eq(tables.difficultyGrades.difficulty, sql`CAST(${tables.climbStats.displayDifficulty} AS INTEGER)`), + ) + ) + .where(eq(tables.climbs.setterUsername, username)) + .orderBy( + sortBy === 'popular' + ? sql`COALESCE(${tables.climbStats.ascensionistCount}, 0) DESC` + : sql`${tables.climbs.createdAt} DESC NULLS LAST` + ) + .limit(limit + 1) + .offset(offset); + + const hasMore = results.length > limit; + const trimmedResults = hasMore ? results.slice(0, limit) : results; + + const climbs: Climb[] = trimmedResults.map((result) => { + const bt = (result.boardType || 'kilter') as BoardName; + return { + uuid: result.uuid, + layoutId: result.layoutId, + setter_username: result.setter_username || '', + name: result.name || '', + description: result.description || '', + frames: result.frames || '', + angle: result.statsAngle ?? 40, + ascensionist_count: Number(result.ascensionist_count || 0), + difficulty: result.difficulty || '', + quality_average: result.quality_average?.toString() || '0', + stars: Math.round((Number(result.quality_average) || 0) * 5), + difficulty_error: result.difficulty_error?.toString() || '0', + benchmark_difficulty: result.benchmark_difficulty && result.benchmark_difficulty > 0 ? result.benchmark_difficulty.toString() : null, + litUpHoldsMap: convertLitUpHoldsStringToMap(result.frames || '', bt)[0], + boardType: bt, + }; + }); + + return { climbs, totalCount, hasMore }; + } + }, + /** * Unified search for users and setters */ diff --git a/packages/backend/src/validation/schemas.ts b/packages/backend/src/validation/schemas.ts index 9f17a264..306ca1ed 100644 --- a/packages/backend/src/validation/schemas.ts +++ b/packages/backend/src/validation/schemas.ts @@ -537,6 +537,23 @@ export const SetterProfileInputSchema = z.object({ export const SetterClimbsInputSchema = z.object({ username: z.string().min(1, 'Username cannot be empty').max(100), boardType: BoardNameSchema.optional(), + layoutId: z.number().int().positive().optional(), + sortBy: z.enum(['popular', 'new']).optional().default('popular'), + limit: z.number().int().min(1).max(100).optional().default(20), + offset: z.number().int().min(0).optional().default(0), +}); + +/** + * Setter climbs full input validation schema (with litUpHoldsMap for thumbnails) + */ +export const SetterClimbsFullInputSchema = z.object({ + username: z.string().min(1, 'Username cannot be empty').max(100), + boardType: BoardNameSchema.optional(), + layoutId: z.number().int().positive().optional(), + sizeId: z.number().int().positive().optional(), + setIds: z.string().optional(), + angle: z.number().int().optional(), + sortBy: z.enum(['popular', 'new']).optional().default('popular'), limit: z.number().int().min(1).max(100).optional().default(20), offset: z.number().int().min(0).optional().default(0), }); diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 792063bf..48b25484 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -46,6 +46,8 @@ export const typeDefs = /* GraphQL */ ` userAscents: Int "Number of times the current user has attempted this climb" userAttempts: Int + "Board type this climb belongs to (e.g. 'kilter', 'tension'). Populated in multi-board contexts." + boardType: String } """ @@ -2163,12 +2165,41 @@ export const typeDefs = /* GraphQL */ ` username: String! "Optional board type filter" boardType: String + "Optional layout ID filter" + layoutId: Int + "Sort order: popular (by ascents, default) or new (by creation date)" + sortBy: String "Maximum number of climbs to return" limit: Int "Number of climbs to skip" offset: Int } + """ + Input for fetching setter climbs with full Climb data (including litUpHoldsMap). + Used by the setter profile page for thumbnail rendering. + """ + input SetterClimbsFullInput { + "The setter's Aurora username" + username: String! + "Board type filter (omit for 'All Boards')" + boardType: String + "Layout ID (required when boardType is provided)" + layoutId: Int + "Size ID (required when boardType is provided)" + sizeId: Int + "Set IDs (required when boardType is provided)" + setIds: String + "Board angle (required when boardType is provided)" + angle: Int + "Sort order: 'popular' (default) or 'new'" + sortBy: String + "Maximum number of climbs to return (default 20)" + limit: Int + "Number of climbs to skip (default 0)" + offset: Int + } + # ============================================ # Comments & Votes Types # ============================================ @@ -2840,6 +2871,12 @@ export const typeDefs = /* GraphQL */ ` """ setterClimbs(input: SetterClimbsInput!): SetterClimbsConnection! + """ + Get climbs created by a setter with full Climb data (including litUpHoldsMap for thumbnails). + Supports multi-board mode when boardType is omitted. + """ + setterClimbsFull(input: SetterClimbsFullInput!): PlaylistClimbsResult! + """ Get activity feed of ascents from followed users. Requires authentication. diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 9bea0515..2aa8f05b 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -26,6 +26,7 @@ export type Climb = { benchmark_difficulty: string | null; userAscents?: number | null; // GraphQL nullable Int userAttempts?: number | null; // GraphQL nullable Int + boardType?: string; // Populated in multi-board contexts }; export type QueueItemUser = { @@ -650,6 +651,8 @@ export type SetterProfileInput = { export type SetterClimbsInput = { username: string; boardType?: string; + layoutId?: number; + sortBy?: 'popular' | 'new'; limit?: number; offset?: number; }; diff --git a/packages/web/app/components/board-page/climbs-list.tsx b/packages/web/app/components/board-page/climbs-list.tsx index 5adb4a49..7a673878 100644 --- a/packages/web/app/components/board-page/climbs-list.tsx +++ b/packages/web/app/components/board-page/climbs-list.tsx @@ -18,6 +18,10 @@ const VIEW_MODE_PREFERENCE_KEY = 'climbListViewMode'; export type ClimbsListProps = { boardDetails: BoardDetails; + /** Map of "boardType:layoutId" -> BoardDetails for multi-board contexts */ + boardDetailsMap?: Record; + /** Set of climb UUIDs that are unsupported (no matching user board) */ + unsupportedClimbs?: Set; climbs: Climb[]; selectedClimbUuid?: string | null; isFetching: boolean; @@ -44,6 +48,8 @@ const ClimbsListSkeleton = ({ aspectRatio, viewMode }: { aspectRatio: number; vi const ClimbsList = ({ boardDetails, + boardDetailsMap, + unsupportedClimbs, climbs, selectedClimbUuid, isFetching, @@ -117,6 +123,18 @@ const ClimbsList = ({ return map; }, [climbs, handleClimbDoubleClick]); + // Resolve per-climb boardDetails when boardDetailsMap is provided + const resolveBoardDetails = useCallback( + (climb: Climb): BoardDetails => { + if (boardDetailsMap && climb.boardType && climb.layoutId != null) { + const key = `${climb.boardType}:${climb.layoutId}`; + return boardDetailsMap[key] || boardDetails; + } + return boardDetails; + }, + [boardDetails, boardDetailsMap], + ); + // Memoize sx prop objects to prevent recreation on every render const headerBoxSx = useMemo(() => ({ display: 'flex', @@ -218,9 +236,10 @@ const ClimbsList = ({ > @@ -239,9 +258,10 @@ const ClimbsList = ({ > ))} diff --git a/packages/web/app/components/board-scroll/board-scroll-card.tsx b/packages/web/app/components/board-scroll/board-scroll-card.tsx index 16e8ea10..22c4d522 100644 --- a/packages/web/app/components/board-scroll/board-scroll-card.tsx +++ b/packages/web/app/components/board-scroll/board-scroll-card.tsx @@ -26,6 +26,8 @@ interface BoardScrollCardProps { storedConfig?: StoredBoardConfig; boardConfigs?: BoardConfigData; selected?: boolean; + disabled?: boolean; + disabledText?: string; size?: 'default' | 'small'; onClick: () => void; } @@ -35,6 +37,8 @@ export default function BoardScrollCard({ storedConfig, boardConfigs, selected, + disabled, + disabledText, size = 'default', onClick, }: BoardScrollCardProps) { @@ -103,10 +107,13 @@ export default function BoardScrollCard({ const isSmall = size === 'small'; const iconSize = isSmall ? 24 : 32; + const handleClick = disabled ? undefined : onClick; + const displayMeta = disabled && disabledText ? disabledText : meta; + return ( -
+
{boardDetails ? ( )}
-
+
{name}
- {meta &&
{meta}
} + {displayMeta &&
{displayMeta}
}
); } diff --git a/packages/web/app/components/board-scroll/board-scroll.module.css b/packages/web/app/components/board-scroll/board-scroll.module.css index 7a35221d..e4d24bce 100644 --- a/packages/web/app/components/board-scroll/board-scroll.module.css +++ b/packages/web/app/components/board-scroll/board-scroll.module.css @@ -79,6 +79,16 @@ text-overflow: ellipsis; } +/* Disabled state */ +.cardSquareDisabled { + opacity: 0.4; + filter: grayscale(100%); +} + +.cardNameDisabled { + color: var(--neutral-400); +} + /* Create New Card */ .createSquare { border: 2px dashed var(--neutral-300); diff --git a/packages/web/app/components/climb-card/climb-card.tsx b/packages/web/app/components/climb-card/climb-card.tsx index 7c486f4a..92b8ab09 100644 --- a/packages/web/app/components/climb-card/climb-card.tsx +++ b/packages/web/app/components/climb-card/climb-card.tsx @@ -21,6 +21,8 @@ type ClimbCardProps = { onCoverClick?: () => void; onCoverDoubleClick?: () => void; selected?: boolean; + /** When true, the card is visually dimmed (greyed out) but still interactive */ + unsupported?: boolean; actions?: React.JSX.Element[]; /** Optional expanded content to render over the cover */ expandedContent?: React.ReactNode; @@ -59,12 +61,14 @@ function ClimbCardWithActions({ onCoverClick, onCoverDoubleClick, selected, + unsupported, }: { climb: Climb; boardDetails: BoardDetails; onCoverClick?: () => void; onCoverDoubleClick?: () => void; selected?: boolean; + unsupported?: boolean; }) { const { mode } = useColorMode(); const isDark = mode === 'dark'; @@ -83,7 +87,7 @@ function ClimbCardWithActions({ } return ( -
+
); } diff --git a/packages/web/app/components/climb-card/climb-list-item.tsx b/packages/web/app/components/climb-card/climb-list-item.tsx index 9a10bcda..b4ea4c61 100644 --- a/packages/web/app/components/climb-card/climb-list-item.tsx +++ b/packages/web/app/components/climb-card/climb-list-item.tsx @@ -28,10 +28,12 @@ type ClimbListItemProps = { climb: Climb; boardDetails: BoardDetails; selected?: boolean; + /** When true, the item is visually dimmed (greyed out) but still interactive */ + unsupported?: boolean; onSelect?: () => void; }; -const ClimbListItem: React.FC = React.memo(({ climb, boardDetails, selected, onSelect }) => { +const ClimbListItem: React.FC = React.memo(({ climb, boardDetails, selected, unsupported, onSelect }) => { const isDark = useIsDarkMode(); const [isActionsOpen, setIsActionsOpen] = useState(false); const queueContext = useOptionalQueueContext(); @@ -66,8 +68,12 @@ const ClimbListItem: React.FC = React.memo(({ climb, boardDe // Memoize style objects to prevent recreation on every render const containerStyle = useMemo( - () => ({ position: 'relative' as const, overflow: 'hidden' as const }), - [], + () => ({ + position: 'relative' as const, + overflow: 'hidden' as const, + ...(unsupported ? { opacity: 0.5, filter: 'grayscale(80%)' } : {}), + }), + [unsupported], ); const leftActionStyle = useMemo( @@ -326,6 +332,7 @@ const ClimbListItem: React.FC = React.memo(({ climb, boardDe }, (prev, next) => { return prev.climb.uuid === next.climb.uuid && prev.selected === next.selected + && prev.unsupported === next.unsupported && prev.boardDetails === next.boardDetails; }); diff --git a/packages/web/app/components/climb-list/setter-climb-list.tsx b/packages/web/app/components/climb-list/setter-climb-list.tsx index cee6477c..709bd02e 100644 --- a/packages/web/app/components/climb-list/setter-climb-list.tsx +++ b/packages/web/app/components/climb-list/setter-climb-list.tsx @@ -1,23 +1,32 @@ 'use client'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import Box from '@mui/material/Box'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemText from '@mui/material/ListItemText'; -import MuiButton from '@mui/material/Button'; import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButton from '@mui/material/ToggleButton'; import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; import { - GET_SETTER_CLIMBS, - type GetSetterClimbsQueryVariables, - type GetSetterClimbsQueryResponse, + GET_SETTER_CLIMBS_FULL, + type GetSetterClimbsFullQueryVariables, + type GetSetterClimbsFullQueryResponse, } from '@/app/lib/graphql/operations'; -import type { SetterClimb } from '@boardsesh/shared-schema'; +import { useMyBoards } from '@/app/hooks/use-my-boards'; +import { useClimbActionsData } from '@/app/hooks/use-climb-actions-data'; +import BoardScrollSection from '@/app/components/board-scroll/board-scroll-section'; +import BoardScrollCard from '@/app/components/board-scroll/board-scroll-card'; +import boardScrollStyles from '@/app/components/board-scroll/board-scroll.module.css'; +import ClimbsList from '@/app/components/board-page/climbs-list'; +import { FavoritesProvider } from '@/app/components/climb-actions/favorites-batch-context'; +import { PlaylistsProvider } from '@/app/components/climb-actions/playlists-batch-context'; +import { getBoardDetailsForPlaylist, getDefaultAngleForBoard } from '@/app/lib/board-config-for-playlist'; +import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { getMoonBoardDetails } from '@/app/lib/moonboard-config'; +import type { UserBoard } from '@boardsesh/shared-schema'; +import type { Climb, BoardDetails, BoardName } from '@/app/lib/types'; + +type SortBy = 'popular' | 'new'; interface SetterClimbListProps { username: string; @@ -25,29 +34,70 @@ interface SetterClimbListProps { authToken?: string | null; } +function getUserBoardDetails(board: UserBoard): BoardDetails | null { + try { + const setIds = board.setIds.split(',').map(Number); + if (board.boardType === 'moonboard') { + return getMoonBoardDetails({ layout_id: board.layoutId, set_ids: setIds }) as BoardDetails; + } + return getBoardDetails({ + board_name: board.boardType as BoardName, + layout_id: board.layoutId, + size_id: board.sizeId, + set_ids: setIds, + }); + } catch { + return null; + } +} + export default function SetterClimbList({ username, boardTypes, authToken }: SetterClimbListProps) { - const [climbs, setClimbs] = useState([]); + const [climbs, setClimbs] = useState([]); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(false); const [totalCount, setTotalCount] = useState(0); - const [selectedBoard, setSelectedBoard] = useState(undefined); + const [selectedBoard, setSelectedBoard] = useState(null); + const [sortBy, setSortBy] = useState('popular'); + + const { boards: myBoards, isLoading: isLoadingBoards } = useMyBoards(true); - const fetchClimbs = useCallback(async (offset = 0, boardType?: string) => { + const fetchClimbs = useCallback(async ( + offset = 0, + board?: UserBoard | null, + sort: SortBy = 'popular', + ) => { setLoading(true); try { const client = createGraphQLHttpClient(authToken ?? null); - const response = await client.request( - GET_SETTER_CLIMBS, - { input: { username, boardType, limit: 20, offset } } + const variables: GetSetterClimbsFullQueryVariables = { + input: { + username, + sortBy: sort, + limit: 20, + offset, + }, + }; + + if (board) { + variables.input.boardType = board.boardType; + variables.input.layoutId = board.layoutId; + variables.input.sizeId = board.sizeId; + variables.input.setIds = board.setIds; + variables.input.angle = board.angle ?? getDefaultAngleForBoard(board.boardType); + } + + const response = await client.request( + GET_SETTER_CLIMBS_FULL, + variables, ); if (offset === 0) { - setClimbs(response.setterClimbs.climbs); + setClimbs(response.setterClimbsFull.climbs); } else { - setClimbs((prev) => [...prev, ...response.setterClimbs.climbs]); + setClimbs((prev) => [...prev, ...response.setterClimbsFull.climbs]); } - setHasMore(response.setterClimbs.hasMore); - setTotalCount(response.setterClimbs.totalCount); + setHasMore(response.setterClimbsFull.hasMore); + setTotalCount(response.setterClimbsFull.totalCount); } catch (error) { console.error('Failed to fetch setter climbs:', error); } finally { @@ -56,22 +106,34 @@ export default function SetterClimbList({ username, boardTypes, authToken }: Set }, [username, authToken]); useEffect(() => { - fetchClimbs(0, selectedBoard); - }, [fetchClimbs, selectedBoard]); + fetchClimbs(0, selectedBoard, sortBy); + }, [fetchClimbs, selectedBoard, sortBy]); - const handleLoadMore = () => { + const handleLoadMore = useCallback(() => { if (!loading && hasMore) { - fetchClimbs(climbs.length, selectedBoard); + fetchClimbs(climbs.length, selectedBoard, sortBy); + } + }, [loading, hasMore, climbs.length, selectedBoard, sortBy, fetchClimbs]); + + const handleSortChange = (_: React.MouseEvent, value: SortBy | null) => { + if (value) { + setSortBy(value); } }; - const handleBoardChange = (_: React.MouseEvent, value: string | null) => { - setSelectedBoard(value ?? undefined); + const handleBoardSelect = (board: UserBoard) => { + setSelectedBoard(board); }; - const navigateToClimb = useCallback(async (climb: SetterClimb) => { + const handleAllSelect = () => { + setSelectedBoard(null); + }; + + const navigateToClimb = useCallback(async (climb: Climb) => { try { - const params = new URLSearchParams({ boardType: climb.boardType, climbUuid: climb.uuid }); + const bt = climb.boardType || selectedBoard?.boardType; + if (!bt) return; + const params = new URLSearchParams({ boardType: bt, climbUuid: climb.uuid }); const res = await fetch(`/api/internal/climb-redirect?${params}`); if (!res.ok) return; const { url } = await res.json(); @@ -79,107 +141,164 @@ export default function SetterClimbList({ username, boardTypes, authToken }: Set } catch { // Silently fail navigation } - }, []); + }, [selectedBoard]); + + // Build boardDetailsMap for multi-board rendering + const { boardDetailsMap, defaultBoardDetails, unsupportedClimbs } = useMemo(() => { + const map: Record = {}; + const unsupported = new Set(); + + // Group user boards by boardType:layoutId + const userBoardsByKey = new Map(); + for (const board of myBoards) { + const key = `${board.boardType}:${board.layoutId}`; + if (!userBoardsByKey.has(key)) { + userBoardsByKey.set(key, board); + } + } + + // For each climb, resolve board details + for (const climb of climbs) { + const bt = climb.boardType; + const layoutId = climb.layoutId; + if (!bt || layoutId == null) continue; + + const key = `${bt}:${layoutId}`; + if (map[key]) continue; + + const userBoard = userBoardsByKey.get(key); + if (userBoard) { + const details = getUserBoardDetails(userBoard); + if (details) { + map[key] = details; + continue; + } + } + + const genericDetails = getBoardDetailsForPlaylist(bt, layoutId); + if (genericDetails) { + map[key] = genericDetails; + } + } + + // Determine unsupported climbs + const userBoardTypes = new Set(myBoards.map((b) => b.boardType)); + for (const climb of climbs) { + if (climb.boardType && !userBoardTypes.has(climb.boardType)) { + unsupported.add(climb.uuid); + } + } + + // Default board details for skeletons + let defaultDetails: BoardDetails | null = null; + if (selectedBoard) { + defaultDetails = getUserBoardDetails(selectedBoard); + } + if (!defaultDetails && myBoards.length > 0) { + defaultDetails = getUserBoardDetails(myBoards[0]); + } + if (!defaultDetails) { + defaultDetails = getBoardDetailsForPlaylist('kilter', 1); + } + + return { + boardDetailsMap: map, + defaultBoardDetails: defaultDetails!, + unsupportedClimbs: unsupported, + }; + }, [climbs, myBoards, selectedBoard]); + + // Climb action data for favorites/playlists context + const climbUuids = useMemo(() => climbs.map((c) => c.uuid), [climbs]); + const actionsBoardName = selectedBoard?.boardType || (climbs[0]?.boardType ?? 'kilter'); + const actionsLayoutId = selectedBoard?.layoutId || (climbs[0]?.layoutId ?? 1); + const actionsAngle = selectedBoard?.angle || getDefaultAngleForBoard(actionsBoardName); + + const { favoritesProviderProps, playlistsProviderProps } = useClimbActionsData({ + boardName: actionsBoardName, + layoutId: actionsLayoutId, + angle: actionsAngle, + climbUuids, + }); + + // Header with sort toggle and count + const headerInline = ( + + + Popular + New + + {totalCount > 0 && ( + + {totalCount} climb{totalCount !== 1 ? 's' : ''} + + )} + + ); return ( - {/* Board type filter */} - {boardTypes && boardTypes.length > 1 && ( - - 0 || isLoadingBoards) && ( + +
- All - {boardTypes.map((bt) => ( - - {bt.charAt(0).toUpperCase() + bt.slice(1)} - - ))} - - +
+ All +
+
+ All Boards +
+
+ {myBoards.map((board) => ( + handleBoardSelect(board)} + /> + ))} +
)} {loading && climbs.length === 0 ? ( - ) : climbs.length === 0 ? ( + ) : climbs.length === 0 && !loading ? ( No climbs found - ) : ( - <> - - {totalCount} climb{totalCount !== 1 ? 's' : ''} - - - {climbs.map((climb) => ( - navigateToClimb(climb)} - sx={{ - cursor: 'pointer', - '&:hover': { backgroundColor: 'action.hover' }, - borderBottom: '1px solid var(--neutral-200)', - py: 1.5, - px: 0, - }} - > - - - {climb.difficultyName && ( - - {climb.difficultyName} - - )} - {climb.angle != null && ( - - {climb.angle}° - - )} - {climb.ascensionistCount != null && climb.ascensionistCount > 0 && ( - - {climb.ascensionistCount} ascent{climb.ascensionistCount !== 1 ? 's' : ''} - - )} - {climb.qualityAverage != null && climb.qualityAverage > 0 && ( - - {'★'.repeat(Math.round(climb.qualityAverage))} - - )} -
- } - /> - - ))} - - {hasMore && ( - - - {loading ? 'Loading...' : `Load more (${climbs.length} of ${totalCount})`} - - - )} - - )} + ) : defaultBoardDetails ? ( + + + + + + ) : null}
); } diff --git a/packages/web/app/lib/graphql/operations/social.ts b/packages/web/app/lib/graphql/operations/social.ts index 8552916f..e6dfc1f5 100644 --- a/packages/web/app/lib/graphql/operations/social.ts +++ b/packages/web/app/lib/graphql/operations/social.ts @@ -6,7 +6,6 @@ import type { UnifiedSearchConnection, FollowingAscentsFeedResult, SetterProfile, - SetterClimbsConnection, } from '@boardsesh/shared-schema'; // ============================================ @@ -288,19 +287,29 @@ export const GET_SETTER_PROFILE = gql` } `; -export const GET_SETTER_CLIMBS = gql` - query GetSetterClimbs($input: SetterClimbsInput!) { - setterClimbs(input: $input) { +// ============================================ +// Setter Climbs Full (with litUpHoldsMap for thumbnails) +// ============================================ + +export const GET_SETTER_CLIMBS_FULL = gql` + query GetSetterClimbsFull($input: SetterClimbsFullInput!) { + setterClimbsFull(input: $input) { climbs { uuid - name - boardType layoutId + boardType + setter_username + name + description + frames angle - difficultyName - qualityAverage - ascensionistCount - createdAt + ascensionist_count + difficulty + quality_average + stars + difficulty_error + benchmark_difficulty + litUpHoldsMap } totalCount hasMore @@ -308,6 +317,28 @@ export const GET_SETTER_CLIMBS = gql` } `; +export interface GetSetterClimbsFullQueryVariables { + input: { + username: string; + boardType?: string; + layoutId?: number; + sizeId?: number; + setIds?: string; + angle?: number; + sortBy?: string; + limit?: number; + offset?: number; + }; +} + +export interface GetSetterClimbsFullQueryResponse { + setterClimbsFull: { + climbs: import('@/app/lib/types').Climb[]; + totalCount: number; + hasMore: boolean; + }; +} + // ============================================ // Unified Search // ============================================ @@ -367,14 +398,6 @@ export interface GetSetterProfileQueryResponse { setterProfile: SetterProfile | null; } -export interface GetSetterClimbsQueryVariables { - input: { username: string; boardType?: string; limit?: number; offset?: number }; -} - -export interface GetSetterClimbsQueryResponse { - setterClimbs: SetterClimbsConnection; -} - export interface SearchUsersAndSettersQueryVariables { input: { query: string; boardType?: string; limit?: number; offset?: number }; } diff --git a/packages/web/app/lib/types.ts b/packages/web/app/lib/types.ts index b67b7c8e..35213af6 100644 --- a/packages/web/app/lib/types.ts +++ b/packages/web/app/lib/types.ts @@ -4,6 +4,7 @@ import { SetIdList } from './board-data'; export type Climb = { uuid: string; layoutId?: number | null; // Layout the climb belongs to - used to identify cross-layout climbs + boardType?: string; // Board type this climb belongs to (e.g. 'kilter', 'tension'). Populated in multi-board contexts. setter_username: string; name: string; description: string; diff --git a/packages/web/app/setter/[setter_username]/page.tsx b/packages/web/app/setter/[setter_username]/page.tsx index de7a8e21..1fdd27b7 100644 --- a/packages/web/app/setter/[setter_username]/page.tsx +++ b/packages/web/app/setter/[setter_username]/page.tsx @@ -1,4 +1,12 @@ +import React from 'react'; +import { Metadata } from 'next'; import SetterProfileContent from './setter-profile-content'; +import styles from '@/app/components/library/playlist-view.module.css'; + +export const metadata: Metadata = { + title: 'Setter Profile | Boardsesh', + description: 'View setter profile and climbs', +}; export default async function SetterProfilePage({ params, @@ -6,5 +14,10 @@ export default async function SetterProfilePage({ params: Promise<{ setter_username: string }>; }) { const { setter_username } = await params; - return ; + + return ( +
+ +
+ ); } diff --git a/packages/web/app/setter/[setter_username]/setter-profile-content.tsx b/packages/web/app/setter/[setter_username]/setter-profile-content.tsx index 94f42026..f670b5a0 100644 --- a/packages/web/app/setter/[setter_username]/setter-profile-content.tsx +++ b/packages/web/app/setter/[setter_username]/setter-profile-content.tsx @@ -1,21 +1,15 @@ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; -import Box from '@mui/material/Box'; -import MuiAvatar from '@mui/material/Avatar'; -import MuiCard from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; import Typography from '@mui/material/Typography'; import Chip from '@mui/material/Chip'; -import CircularProgress from '@mui/material/CircularProgress'; -import MuiButton from '@mui/material/Button'; -import { PersonOutlined } from '@mui/icons-material'; +import Box from '@mui/material/Box'; +import { PersonOutlined, SentimentDissatisfiedOutlined } from '@mui/icons-material'; import { useSession } from 'next-auth/react'; -import Logo from '@/app/components/brand/logo'; import BackButton from '@/app/components/back-button'; import FollowButton from '@/app/components/ui/follow-button'; import SetterClimbList from '@/app/components/climb-list/setter-climb-list'; -import { EmptyState } from '@/app/components/ui/empty-state'; +import { LoadingSpinner } from '@/app/components/ui/loading-spinner'; import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; import { GET_SETTER_PROFILE, @@ -24,8 +18,9 @@ import { type GetSetterProfileQueryVariables, type GetSetterProfileQueryResponse, } from '@/app/lib/graphql/operations'; +import { themeTokens } from '@/app/theme/theme-config'; import type { SetterProfile } from '@boardsesh/shared-schema'; -import styles from './setter-profile.module.css'; +import styles from '@/app/components/library/playlist-view.module.css'; interface SetterProfileContentProps { username: string; @@ -58,28 +53,21 @@ export default function SetterProfileContent({ username }: SetterProfileContentP if (loading) { return ( - - - - - +
+ +
); } if (!profile) { return ( - - - - - - Setter Profile - - - - - - +
+ +
Setter Not Found
+
+ This setter profile may not exist or may have been removed. +
+
); } @@ -88,87 +76,83 @@ export default function SetterProfileContent({ username }: SetterProfileContentP const authToken = (session as { authToken?: string } | null)?.authToken ?? null; return ( - - + <> + {/* Back Button */} +
- - - Setter Profile - - +
- - {/* Profile Card */} - - -
- - {!avatarUrl && } - -
- - - {displayName} - - ({ input: { setterUsername: id } })} - onFollowChange={(isFollowing) => { - if (profile) { - setProfile({ - ...profile, - followerCount: profile.followerCount + (isFollowing ? 1 : -1), - isFollowedByMe: isFollowing, - }); - } - }} - /> - - - {profile.followerCount} follower{profile.followerCount !== 1 ? 's' : ''} · {profile.climbCount} climb{profile.climbCount !== 1 ? 's' : ''} - -
- {profile.boardTypes.map((bt) => ( - - ))} -
- {profile.linkedUserId && ( - - View Boardsesh profile - - )} + {/* Main Content */} +
+ {/* Hero Card */} +
+
+
+ {avatarUrl ? ( + {displayName} + ) : ( + + )} +
+
+ + {displayName} + +
+ + {profile.followerCount} {profile.followerCount === 1 ? 'follower' : 'followers'} + + + {profile.climbCount} {profile.climbCount === 1 ? 'climb' : 'climbs'} +
+ + {profile.boardTypes.map((bt) => ( + + ))} + + ({ input: { setterUsername: id } })} + onFollowChange={(isFollowing) => { + if (profile) { + setProfile({ + ...profile, + followerCount: profile.followerCount + (isFollowing ? 1 : -1), + isFollowedByMe: isFollowing, + }); + } + }} + />
- - +
+
- {/* Created Climbs */} - - - - Created Climbs - - - - - - + {/* Climbs Section */} +
+ +
+
+ ); } diff --git a/packages/web/app/setter/[setter_username]/setter-profile.module.css b/packages/web/app/setter/[setter_username]/setter-profile.module.css deleted file mode 100644 index abb364ba..00000000 --- a/packages/web/app/setter/[setter_username]/setter-profile.module.css +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Setter profile page styles - * Uses CSS custom properties from index.css (derived from theme-config.ts) - */ - -.layout { - min-height: 100vh; - background: var(--background, #F9FAFB); -} - -.header { - background: var(--surface, #FFFFFF); - padding: 0 16px; - display: flex; - align-items: center; - gap: 16px; - box-shadow: var(--shadow-xs); - height: 64px; -} - -.headerTitle { - margin: 0 !important; - flex: 1; -} - -.content { - padding: 24px; - max-width: 800px; - margin: 0 auto; - width: 100%; -} - -.loadingContent { - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; -} - -.profileCard { - margin-bottom: 24px; -} - -.profileInfo { - display: flex; - align-items: center; - gap: 16px; -} - -.profileDetails { - display: flex; - flex-direction: column; -} - -.displayName { - margin: 0 !important; -} - -.boardBadges { - display: flex; - gap: 8px; - margin-top: 4px; - flex-wrap: wrap; -} - -.climbsCard { - margin-bottom: 24px; -} - -.filterRow { - margin-bottom: 16px; -} - -.climbList { - list-style: none; - padding: 0; - margin: 0; -} - -.climbItem { - display: flex; - align-items: center; - padding: 12px 0; - border-bottom: 1px solid var(--neutral-200); - gap: 12px; - text-decoration: none; - color: inherit; - cursor: pointer; -} - -.climbItem:last-child { - border-bottom: none; -} - -.climbItem:hover { - background-color: var(--neutral-50); -} - -.climbInfo { - flex: 1; - min-width: 0; -} - -.climbName { - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.climbMeta { - display: flex; - gap: 12px; - align-items: center; - margin-top: 2px; -} - -@media (max-width: 768px) { - .content { - padding: 16px; - } - - .header { - padding: 0 12px; - gap: 8px; - } - - .profileInfo { - flex-direction: column; - text-align: center; - } - - .profileDetails { - align-items: center; - } - - .boardBadges { - justify-content: center; - } -} From e4222469429593dd0ceb01f7ed3cf414fa4f5928 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sat, 14 Feb 2026 12:55:45 +0100 Subject: [PATCH 4/5] Fix desktop climbs list grid to render 2 cards per row Account for the flex gap in the card width calculation so two cards fit side-by-side instead of wrapping. Co-Authored-By: Claude Opus 4.6 --- packages/web/app/components/board-page/climbs-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/app/components/board-page/climbs-list.tsx b/packages/web/app/components/board-page/climbs-list.tsx index 7a673878..eb00175b 100644 --- a/packages/web/app/components/board-page/climbs-list.tsx +++ b/packages/web/app/components/board-page/climbs-list.tsx @@ -165,7 +165,7 @@ const ClimbsList = ({ }), []); const cardBoxSx = useMemo(() => ({ - width: { xs: '100%', lg: '50%' }, + width: { xs: '100%', lg: `calc(50% - ${themeTokens.spacing[4] / 2}px)` }, }), []); const sentinelBoxSx = useMemo(() => ({ From bbd0d048afd48248284d13a533528e2f3006f775 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sun, 15 Feb 2026 07:03:05 +0100 Subject: [PATCH 5/5] Fix PR review feedback: N+1 query, pagination, error logging, and tests - Batch notification enrichment to replace per-group DB loop with 3 max queries using inArray - Fix unified search pagination by fetching limit+offset rows from each source before in-memory sort and slice - Add console.error logging to silent navigation catch blocks - Add composite index on (boardType, setterUsername) for board_climbs - Extract DEFAULT_ANGLE constant to replace hardcoded magic number 40 - Add validation schema tests for setter follow inputs - Add resolver tests for followSetter and unfollowSetter mutations Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/setter-follows.test.ts | 245 ++++++++++++++++++ .../src/__tests__/social-validation.test.ts | 130 ++++++++++ .../graphql/resolvers/social/notifications.ts | 88 +++++-- .../resolvers/social/setter-follows.ts | 11 +- packages/db/src/schema/boards/unified.ts | 4 + .../climb-list/setter-climb-list.tsx | 4 +- .../notifications/notification-list.tsx | 4 +- .../web/app/lib/board-config-for-playlist.ts | 7 +- 8 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 packages/backend/src/__tests__/setter-follows.test.ts diff --git a/packages/backend/src/__tests__/setter-follows.test.ts b/packages/backend/src/__tests__/setter-follows.test.ts new file mode 100644 index 00000000..42371fd2 --- /dev/null +++ b/packages/backend/src/__tests__/setter-follows.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// All mock variables must be inside vi.hoisted() to avoid "Cannot access before initialization" errors +const { mockDb } = vi.hoisted(() => { + const mockDb = { + execute: vi.fn(), + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + }; + + return { mockDb }; +}); + +vi.mock('../db/client', () => ({ + db: mockDb, +})); + +vi.mock('../events/index', () => ({ + publishSocialEvent: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../utils/rate-limiter', () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('../utils/redis-rate-limiter', () => ({ + checkRateLimitRedis: vi.fn(), +})); + +vi.mock('../db/queries/util/table-select', () => ({ + getBoardTables: vi.fn().mockReturnValue({ + climbs: { uuid: 'uuid', layoutId: 'layoutId', boardType: 'boardType', setterUsername: 'setterUsername', name: 'name', description: 'description', frames: 'frames', createdAt: 'createdAt' }, + climbStats: { climbUuid: 'climbUuid', boardType: 'boardType', angle: 'angle', ascensionistCount: 'ascensionistCount', qualityAverage: 'qualityAverage', difficultyAverage: 'difficultyAverage', displayDifficulty: 'displayDifficulty', benchmarkDifficulty: 'benchmarkDifficulty' }, + difficultyGrades: { boardType: 'boardType', difficulty: 'difficulty', boulderName: 'boulderName' }, + }), + isValidBoardName: vi.fn().mockReturnValue(true), +})); + +vi.mock('../db/queries/util/hold-state', () => ({ + convertLitUpHoldsStringToMap: vi.fn().mockReturnValue([{}]), +})); + +import type { ConnectionContext } from '@boardsesh/shared-schema'; +import { setterFollowMutations } from '../graphql/resolvers/social/setter-follows'; + +function makeCtx(overrides: Partial = {}): ConnectionContext { + return { + connectionId: 'conn-1', + isAuthenticated: true, + userId: 'user-123', + sessionId: null, + boardPath: null, + controllerId: null, + controllerApiKey: null, + ...overrides, + } as ConnectionContext; +} + +/** + * Create a chainable mock object that resolves at any point. + * Each method returns the same chain, and the chain is also a thenable + * that resolves with the provided value (for await). + */ +function createMockChain(resolveValue: unknown = []): Record { + const chain: Record = {}; + const methods = [ + 'select', 'from', 'where', 'leftJoin', 'innerJoin', + 'groupBy', 'orderBy', 'limit', 'offset', + 'insert', 'values', 'onConflictDoNothing', 'returning', + 'delete', 'update', 'set', + ]; + + // Make the chain a thenable (for destructuring awaits like `const [x] = await db.select()...`) + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(resolveValue).then(resolve); + + for (const method of methods) { + chain[method] = vi.fn((..._args: unknown[]) => chain); + } + + return chain; +} + +describe('followSetter mutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should throw for unauthenticated users', async () => { + const ctx = makeCtx({ isAuthenticated: false }); + await expect( + setterFollowMutations.followSetter(null, { input: { setterUsername: 'setter1' } }, ctx), + ).rejects.toThrow('Authentication required'); + }); + + it('should throw if setter does not exist', async () => { + const ctx = makeCtx(); + + // select().from().where().limit() → [{ count: 0 }] + const existsChain = createMockChain([{ count: 0 }]); + mockDb.select.mockReturnValueOnce(existsChain); + + await expect( + setterFollowMutations.followSetter(null, { input: { setterUsername: 'nonexistent' } }, ctx), + ).rejects.toThrow('Setter not found'); + }); + + it('should insert follow and return true', async () => { + const ctx = makeCtx(); + + // 1. Setter exists check → count: 1 + const existsChain = createMockChain([{ count: 1 }]); + mockDb.select.mockReturnValueOnce(existsChain); + + // 2. Insert follow → returns row (new follow) + const insertChain = createMockChain([{ id: 1, followerId: 'user-123', setterUsername: 'setter1' }]); + mockDb.insert.mockReturnValueOnce(insertChain); + + // 3. Check linked user → no linked users + const linkedChain = createMockChain([]); + mockDb.select.mockReturnValueOnce(linkedChain); + + const result = await setterFollowMutations.followSetter( + null, + { input: { setterUsername: 'setter1' } }, + ctx, + ); + + expect(result).toBe(true); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it('should handle idempotent follow (onConflictDoNothing returns empty)', async () => { + const ctx = makeCtx(); + + // Setter exists + const existsChain = createMockChain([{ count: 1 }]); + mockDb.select.mockReturnValueOnce(existsChain); + + // Insert returns empty (conflict, already following) + const insertChain = createMockChain([]); + mockDb.insert.mockReturnValueOnce(insertChain); + + const result = await setterFollowMutations.followSetter( + null, + { input: { setterUsername: 'setter1' } }, + ctx, + ); + + expect(result).toBe(true); + // No additional insert for user_follows since result was empty + expect(mockDb.insert).toHaveBeenCalledTimes(1); + }); + + it('should create user_follows when setter has linked Boardsesh account', async () => { + const ctx = makeCtx(); + + // 1. Setter exists + const existsChain = createMockChain([{ count: 1 }]); + mockDb.select.mockReturnValueOnce(existsChain); + + // 2. Insert follow returns new row + const insertChain = createMockChain([{ id: 1 }]); + mockDb.insert.mockReturnValueOnce(insertChain); + + // 3. Linked user found + const linkedChain = createMockChain([{ userId: 'linked-user-456' }]); + mockDb.select.mockReturnValueOnce(linkedChain); + + // 4. user_follows insert + const userFollowInsertChain = createMockChain(undefined); + mockDb.insert.mockReturnValueOnce(userFollowInsertChain); + + const result = await setterFollowMutations.followSetter( + null, + { input: { setterUsername: 'setter1' } }, + ctx, + ); + + expect(result).toBe(true); + // Insert called twice: once for setter_follows, once for user_follows + expect(mockDb.insert).toHaveBeenCalledTimes(2); + }); +}); + +describe('unfollowSetter mutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should throw for unauthenticated users', async () => { + const ctx = makeCtx({ isAuthenticated: false }); + await expect( + setterFollowMutations.unfollowSetter(null, { input: { setterUsername: 'setter1' } }, ctx), + ).rejects.toThrow('Authentication required'); + }); + + it('should delete follow and return true', async () => { + const ctx = makeCtx(); + + // Delete setter_follows + const deleteChain = createMockChain(undefined); + mockDb.delete.mockReturnValueOnce(deleteChain); + + // Check linked user → no linked users + const linkedChain = createMockChain([]); + mockDb.select.mockReturnValueOnce(linkedChain); + + const result = await setterFollowMutations.unfollowSetter( + null, + { input: { setterUsername: 'setter1' } }, + ctx, + ); + + expect(result).toBe(true); + expect(mockDb.delete).toHaveBeenCalledTimes(1); + }); + + it('should also delete user_follows when setter has linked account', async () => { + const ctx = makeCtx(); + + // Delete setter_follows + const deleteChain = createMockChain(undefined); + mockDb.delete.mockReturnValueOnce(deleteChain); + + // Check linked user → found + const linkedChain = createMockChain([{ userId: 'linked-user-456' }]); + mockDb.select.mockReturnValueOnce(linkedChain); + + // Delete user_follows + const deleteUserFollowChain = createMockChain(undefined); + mockDb.delete.mockReturnValueOnce(deleteUserFollowChain); + + const result = await setterFollowMutations.unfollowSetter( + null, + { input: { setterUsername: 'setter1' } }, + ctx, + ); + + expect(result).toBe(true); + // Delete called twice: setter_follows and user_follows + expect(mockDb.delete).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/backend/src/__tests__/social-validation.test.ts b/packages/backend/src/__tests__/social-validation.test.ts index ee584d31..70cf3e6d 100644 --- a/packages/backend/src/__tests__/social-validation.test.ts +++ b/packages/backend/src/__tests__/social-validation.test.ts @@ -4,6 +4,10 @@ import { FollowListInputSchema, SearchUsersInputSchema, FollowingAscentsFeedInputSchema, + FollowSetterInputSchema, + SetterProfileInputSchema, + SetterClimbsInputSchema, + SetterClimbsFullInputSchema, } from '../validation/schemas'; describe('Social Validation Schemas', () => { @@ -175,4 +179,130 @@ describe('Social Validation Schemas', () => { expect(result.success).toBe(false); }); }); + + describe('FollowSetterInputSchema', () => { + it('should accept a valid setter username', () => { + const result = FollowSetterInputSchema.safeParse({ setterUsername: 'climber42' }); + expect(result.success).toBe(true); + }); + + it('should reject an empty setter username', () => { + const result = FollowSetterInputSchema.safeParse({ setterUsername: '' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('cannot be empty'); + } + }); + + it('should reject setter username exceeding max length', () => { + const result = FollowSetterInputSchema.safeParse({ setterUsername: 'a'.repeat(101) }); + expect(result.success).toBe(false); + }); + }); + + describe('SetterProfileInputSchema', () => { + it('should accept a valid username', () => { + const result = SetterProfileInputSchema.safeParse({ username: 'climber42' }); + expect(result.success).toBe(true); + }); + + it('should reject an empty username', () => { + const result = SetterProfileInputSchema.safeParse({ username: '' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('cannot be empty'); + } + }); + }); + + describe('SetterClimbsInputSchema', () => { + it('should accept valid input with defaults', () => { + const result = SetterClimbsInputSchema.safeParse({ username: 'climber42' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('popular'); + expect(result.data.limit).toBe(20); + expect(result.data.offset).toBe(0); + } + }); + + it('should accept custom limit and offset', () => { + const result = SetterClimbsInputSchema.safeParse({ + username: 'climber42', + limit: 50, + offset: 10, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(10); + } + }); + + it('should reject limit exceeding max (100)', () => { + const result = SetterClimbsInputSchema.safeParse({ + username: 'climber42', + limit: 101, + }); + expect(result.success).toBe(false); + }); + + it('should accept valid sortBy values', () => { + const popular = SetterClimbsInputSchema.safeParse({ username: 'x', sortBy: 'popular' }); + const newSort = SetterClimbsInputSchema.safeParse({ username: 'x', sortBy: 'new' }); + expect(popular.success).toBe(true); + expect(newSort.success).toBe(true); + }); + + it('should reject invalid sortBy values', () => { + const result = SetterClimbsInputSchema.safeParse({ username: 'x', sortBy: 'invalid' }); + expect(result.success).toBe(false); + }); + }); + + describe('SetterClimbsFullInputSchema', () => { + it('should accept valid input with defaults', () => { + const result = SetterClimbsFullInputSchema.safeParse({ username: 'climber42' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('popular'); + expect(result.data.limit).toBe(20); + expect(result.data.offset).toBe(0); + } + }); + + it('should accept optional angle, sizeId, and setIds', () => { + const result = SetterClimbsFullInputSchema.safeParse({ + username: 'climber42', + angle: 40, + sizeId: 10, + setIds: '1,2,3', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.angle).toBe(40); + expect(result.data.sizeId).toBe(10); + expect(result.data.setIds).toBe('1,2,3'); + } + }); + + it('should reject limit exceeding max (100)', () => { + const result = SetterClimbsFullInputSchema.safeParse({ + username: 'climber42', + limit: 101, + }); + expect(result.success).toBe(false); + }); + + it('should accept optional boardType', () => { + const result = SetterClimbsFullInputSchema.safeParse({ + username: 'climber42', + boardType: 'kilter', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.boardType).toBe('kilter'); + } + }); + }); }); diff --git a/packages/backend/src/graphql/resolvers/social/notifications.ts b/packages/backend/src/graphql/resolvers/social/notifications.ts index 9854b059..b7e139d5 100644 --- a/packages/backend/src/graphql/resolvers/social/notifications.ts +++ b/packages/backend/src/graphql/resolvers/social/notifications.ts @@ -1,4 +1,4 @@ -import { eq, and, isNull, count, sql } from 'drizzle-orm'; +import { eq, and, isNull, count, sql, inArray } from 'drizzle-orm'; import type { ConnectionContext } from '@boardsesh/shared-schema'; import { db } from '../../../db/client'; import * as dbSchema from '@boardsesh/db/schema'; @@ -226,24 +226,80 @@ export const socialNotificationQueries = { }; }); - // Enrich groups with climb/proposal data + // Enrich groups with climb/proposal data (batched to avoid N+1) const proposalTypes = ['proposal_created', 'proposal_approved', 'proposal_rejected', 'proposal_vote']; const climbTypes = ['new_climb', 'new_climb_global']; + // Collect entity IDs by type + const climbEntityIds: string[] = []; + const proposalEntityIds: string[] = []; for (const group of groups) { if (!group.entityId) continue; + if (group.type === 'new_climbs_synced' || climbTypes.includes(group.type)) { + climbEntityIds.push(group.entityId); + } else if (proposalTypes.includes(group.type)) { + proposalEntityIds.push(group.entityId); + } + } - if (group.type === 'new_climbs_synced') { - // For synced climb notifications, entityId is the first climb UUID - const [climb] = await db + // Batch-fetch climbs + const climbMap = new Map(); + if (climbEntityIds.length > 0) { + const climbRows = await db + .select({ + uuid: dbSchema.boardClimbs.uuid, + name: dbSchema.boardClimbs.name, + boardType: dbSchema.boardClimbs.boardType, + setterUsername: dbSchema.boardClimbs.setterUsername, + }) + .from(dbSchema.boardClimbs) + .where(inArray(dbSchema.boardClimbs.uuid, climbEntityIds)); + for (const row of climbRows) { + climbMap.set(row.uuid, { name: row.name, boardType: row.boardType, setterUsername: row.setterUsername }); + } + } + + // Batch-fetch proposals + const proposalMap = new Map(); + if (proposalEntityIds.length > 0) { + const proposalRows = await db + .select({ + uuid: dbSchema.climbProposals.uuid, + climbUuid: dbSchema.climbProposals.climbUuid, + boardType: dbSchema.climbProposals.boardType, + }) + .from(dbSchema.climbProposals) + .where(inArray(dbSchema.climbProposals.uuid, proposalEntityIds)); + for (const row of proposalRows) { + proposalMap.set(row.uuid, { climbUuid: row.climbUuid, boardType: row.boardType }); + } + + // Fetch climb names for proposal-linked climbs + const proposalClimbUuids = [...new Set([...proposalMap.values()].map((p) => p.climbUuid))]; + if (proposalClimbUuids.length > 0) { + const proposalClimbRows = await db .select({ + uuid: dbSchema.boardClimbs.uuid, name: dbSchema.boardClimbs.name, boardType: dbSchema.boardClimbs.boardType, setterUsername: dbSchema.boardClimbs.setterUsername, }) .from(dbSchema.boardClimbs) - .where(eq(dbSchema.boardClimbs.uuid, group.entityId)) - .limit(1); + .where(inArray(dbSchema.boardClimbs.uuid, proposalClimbUuids)); + for (const row of proposalClimbRows) { + if (!climbMap.has(row.uuid)) { + climbMap.set(row.uuid, { name: row.name, boardType: row.boardType, setterUsername: row.setterUsername }); + } + } + } + } + + // Enrich groups using map lookups (no DB calls) + for (const group of groups) { + if (!group.entityId) continue; + + if (group.type === 'new_climbs_synced') { + const climb = climbMap.get(group.entityId); if (climb) { group.climbUuid = group.entityId; group.climbName = climb.name ?? undefined; @@ -251,31 +307,19 @@ export const socialNotificationQueries = { group.setterUsername = climb.setterUsername ?? undefined; } } else if (climbTypes.includes(group.type)) { - const [climb] = await db - .select({ name: dbSchema.boardClimbs.name, boardType: dbSchema.boardClimbs.boardType }) - .from(dbSchema.boardClimbs) - .where(eq(dbSchema.boardClimbs.uuid, group.entityId)) - .limit(1); + const climb = climbMap.get(group.entityId); if (climb) { group.climbUuid = group.entityId; group.climbName = climb.name ?? undefined; group.boardType = climb.boardType; } } else if (proposalTypes.includes(group.type)) { - const [proposal] = await db - .select({ climbUuid: dbSchema.climbProposals.climbUuid, boardType: dbSchema.climbProposals.boardType }) - .from(dbSchema.climbProposals) - .where(eq(dbSchema.climbProposals.uuid, group.entityId)) - .limit(1); + const proposal = proposalMap.get(group.entityId); if (proposal) { group.proposalUuid = group.entityId; group.climbUuid = proposal.climbUuid; group.boardType = proposal.boardType; - const [climb] = await db - .select({ name: dbSchema.boardClimbs.name }) - .from(dbSchema.boardClimbs) - .where(eq(dbSchema.boardClimbs.uuid, proposal.climbUuid)) - .limit(1); + const climb = climbMap.get(proposal.climbUuid); group.climbName = climb?.name ?? undefined; } } diff --git a/packages/backend/src/graphql/resolvers/social/setter-follows.ts b/packages/backend/src/graphql/resolvers/social/setter-follows.ts index 4457bdbc..26f85356 100644 --- a/packages/backend/src/graphql/resolvers/social/setter-follows.ts +++ b/packages/backend/src/graphql/resolvers/social/setter-follows.ts @@ -16,6 +16,9 @@ import { getBoardTables, isValidBoardName } from '../../../db/queries/util/table import { getSizeEdges } from '../../../db/queries/util/product-sizes-data'; import { convertLitUpHoldsStringToMap } from '../../../db/queries/util/hold-state'; +/** Default angle fallback when no angle specified or no stats exist. 40 is the most common training angle. */ +const DEFAULT_ANGLE = 40; + export const setterFollowQueries = { /** * Get a setter profile by username @@ -215,7 +218,7 @@ export const setterFollowQueries = { throw new Error(`Invalid board name: ${boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`); } - const angle = validatedInput.angle ?? 40; + const angle = validatedInput.angle ?? DEFAULT_ANGLE; const tables = getBoardTables(boardName); // Get total count @@ -386,7 +389,7 @@ export const setterFollowQueries = { name: result.name || '', description: result.description || '', frames: result.frames || '', - angle: result.statsAngle ?? 40, + angle: result.statsAngle ?? DEFAULT_ANGLE, ascensionist_count: Number(result.ascensionist_count || 0), difficulty: result.difficulty || '', quality_average: result.quality_average?.toString() || '0', @@ -449,7 +452,7 @@ export const setterFollowQueries = { sql`case when ${dbSchema.userProfiles.displayName} ilike ${prefixPattern} or ${dbSchema.users.name} ilike ${prefixPattern} then 0 else 1 end`, sql`(select count(*)::int from boardsesh_ticks where user_id = ${dbSchema.users.id} and created_at > ${thirtyDaysAgoIso}) DESC`, ) - .limit(limit); + .limit(limit + offset); // 2. Search setters from board_climbs const setterResults = await db @@ -467,7 +470,7 @@ export const setterFollowQueries = { ) .groupBy(dbSchema.boardClimbs.setterUsername) .orderBy(sql`count(DISTINCT ${dbSchema.boardClimbs.uuid}) DESC`) - .limit(limit); + .limit(limit + offset); // 3. Get linked usernames to de-duplicate const linkedUsernames = new Set(); diff --git a/packages/db/src/schema/boards/unified.ts b/packages/db/src/schema/boards/unified.ts index 15dd033e..2ac77aa7 100644 --- a/packages/db/src/schema/boards/unified.ts +++ b/packages/db/src/schema/boards/unified.ts @@ -260,6 +260,10 @@ export const boardClimbs = pgTable('board_climbs', { table.edgeBottom, table.edgeTop, ), + setterUsernameIdx: index('board_climbs_setter_username_idx').on( + table.boardType, + table.setterUsername, + ), // Note: No FK to board_layouts - climbs may reference layouts that don't exist during sync })); diff --git a/packages/web/app/components/climb-list/setter-climb-list.tsx b/packages/web/app/components/climb-list/setter-climb-list.tsx index 709bd02e..96519494 100644 --- a/packages/web/app/components/climb-list/setter-climb-list.tsx +++ b/packages/web/app/components/climb-list/setter-climb-list.tsx @@ -138,8 +138,8 @@ export default function SetterClimbList({ username, boardTypes, authToken }: Set if (!res.ok) return; const { url } = await res.json(); if (url) window.location.href = url; - } catch { - // Silently fail navigation + } catch (error) { + console.error('Failed to navigate to climb:', error); } }, [selectedBoard]); diff --git a/packages/web/app/components/notifications/notification-list.tsx b/packages/web/app/components/notifications/notification-list.tsx index 870dd14a..7d52408e 100644 --- a/packages/web/app/components/notifications/notification-list.tsx +++ b/packages/web/app/components/notifications/notification-list.tsx @@ -36,8 +36,8 @@ export default function NotificationList() { if (!res.ok) return; const { url } = await res.json(); if (url) router.push(url); - } catch { - // Silently fail navigation + } catch (error) { + console.error('Failed to navigate to climb:', error); } }, [router], diff --git a/packages/web/app/lib/board-config-for-playlist.ts b/packages/web/app/lib/board-config-for-playlist.ts index 592058f5..cde3a20d 100644 --- a/packages/web/app/lib/board-config-for-playlist.ts +++ b/packages/web/app/lib/board-config-for-playlist.ts @@ -89,10 +89,13 @@ export function getDefaultLayoutForBoard(boardType: string): number | null { return ids.length > 0 ? ids[0] : null; } +/** Default angle fallback when no angle specified. 40 is the most common training angle. */ +const DEFAULT_ANGLE = 40; + /** * Get a default angle for a board type. - * Returns 40 for all board types. + * Returns the default training angle for all board types. */ export function getDefaultAngleForBoard(_boardType: string): number { - return 40; + return DEFAULT_ANGLE; }