From e9c15bf574b455068a12a1973d5425ce1e65a76f Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 11 Feb 2026 16:48:54 +1100 Subject: [PATCH] Finish milestone 7 --- docs/social-features-plan.md | 2 +- packages/backend/src/events/feed-fanout.ts | 29 + packages/backend/src/events/index.ts | 131 +- .../backend/src/events/notification-worker.ts | 128 +- .../src/events/recipient-resolution.ts | 43 + .../src/graphql/resolvers/climbs/mutations.ts | 242 + .../backend/src/graphql/resolvers/index.ts | 7 + .../social/new-climb-feed-subscription.ts | 31 + .../social/new-climb-subscriptions.ts | 170 + packages/backend/src/pubsub/index.ts | 76 +- packages/backend/src/pubsub/redis-adapter.ts | 50 +- packages/backend/src/validation/schemas.ts | 45 + .../db/drizzle/0050_military_wonder_man.sql | 19 + packages/db/drizzle/meta/0050_snapshot.json | 4076 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/src/schema/app/index.ts | 1 + .../src/schema/app/new-climb-subscriptions.ts | 21 + packages/shared-schema/src/schema.ts | 116 + packages/shared-schema/src/types.ts | 76 + .../activity-feed/ascent-thumbnail.tsx | 43 +- packages/web/app/components/back-button.tsx | 22 +- .../components/board-bluetooth-control/plan | 355 ++ .../board-entity/board-selector-pills.tsx | 1 + .../board-provider/board-provider-context.tsx | 60 +- .../create-climb/create-climb-form.tsx | 59 +- .../app/components/new-climb-feed/index.ts | 3 + .../new-climb-feed/new-climb-feed-item.tsx | 113 + .../new-climb-feed/new-climb-feed.tsx | 162 + .../new-climb-feed/subscribe-button.tsx | 109 + packages/web/app/home-page-content.tsx | 108 +- .../web/app/lib/graphql/operations/index.ts | 1 + .../lib/graphql/operations/new-climb-feed.ts | 140 + 32 files changed, 6377 insertions(+), 71 deletions(-) create mode 100644 packages/backend/src/graphql/resolvers/climbs/mutations.ts create mode 100644 packages/backend/src/graphql/resolvers/social/new-climb-feed-subscription.ts create mode 100644 packages/backend/src/graphql/resolvers/social/new-climb-subscriptions.ts create mode 100644 packages/db/drizzle/0050_military_wonder_man.sql create mode 100644 packages/db/drizzle/meta/0050_snapshot.json create mode 100644 packages/db/src/schema/app/new-climb-subscriptions.ts create mode 100644 packages/web/app/components/board-bluetooth-control/plan create mode 100644 packages/web/app/components/new-climb-feed/index.ts create mode 100644 packages/web/app/components/new-climb-feed/new-climb-feed-item.tsx create mode 100644 packages/web/app/components/new-climb-feed/new-climb-feed.tsx create mode 100644 packages/web/app/components/new-climb-feed/subscribe-button.tsx create mode 100644 packages/web/app/lib/graphql/operations/new-climb-feed.ts diff --git a/docs/social-features-plan.md b/docs/social-features-plan.md index e768499b1..de94612e0 100644 --- a/docs/social-features-plan.md +++ b/docs/social-features-plan.md @@ -2895,7 +2895,7 @@ Each milestone creates only the DB tables and types it needs, and delivers testa - Admin freezes a climb → no new proposals allowed - Community status badges appear on climb cards -### Milestone 7: New Climb Feed + Subscriptions +### Milestone 7: New Climb Feed + Subscriptions [COMPLETED] **User value**: "I can see new climbs being set on my board type and get notified." diff --git a/packages/backend/src/events/feed-fanout.ts b/packages/backend/src/events/feed-fanout.ts index 58cf9f2f7..3c6d52883 100644 --- a/packages/backend/src/events/feed-fanout.ts +++ b/packages/backend/src/events/feed-fanout.ts @@ -42,3 +42,32 @@ export async function fanoutFeedItems(event: SocialEvent): Promise { await db.insert(dbSchema.feedItems).values(batch); } } + +/** + * Fan out new climb feed items to followers of the setter. + */ +export async function fanoutNewClimbFeedItems(event: SocialEvent): Promise { + const followers = await db + .select({ followerId: dbSchema.userFollows.followerId }) + .from(dbSchema.userFollows) + .where(eq(dbSchema.userFollows.followingId, event.actorId)); + + if (followers.length === 0) return; + + const metadata = buildFeedItemMetadata(event); + + const rows = followers.map((f) => ({ + recipientId: f.followerId, + actorId: event.actorId, + type: 'new_climb' as const, + entityType: 'climb' as SocialEntityType, + entityId: event.entityId, + boardUuid: null, + metadata, + })); + + for (let i = 0; i < rows.length; i += FANOUT_BATCH_SIZE) { + const batch = rows.slice(i, i + FANOUT_BATCH_SIZE); + await db.insert(dbSchema.feedItems).values(batch); + } +} diff --git a/packages/backend/src/events/index.ts b/packages/backend/src/events/index.ts index e90048f9b..7c78ca36c 100644 --- a/packages/backend/src/events/index.ts +++ b/packages/backend/src/events/index.ts @@ -4,8 +4,12 @@ import { pubsub } from '../pubsub/index'; import { db } from '../db/client'; import * as dbSchema from '@boardsesh/db/schema'; import { eq, and, sql } from 'drizzle-orm'; -import { fanoutFeedItems } from './feed-fanout'; +import { fanoutFeedItems, fanoutNewClimbFeedItems } from './feed-fanout'; import crypto from 'crypto'; +import { + resolveClimbCreatedFollowerRecipients, + resolveClimbCreatedSubscriptionRecipients, +} from './recipient-resolution'; export const eventBroker = new EventBroker(); @@ -130,6 +134,131 @@ async function createInlineNotification(event: SocialEvent): Promise { // Multi-recipient notification (all climbers) — handled by NotificationWorker only return; } + case 'climb.created': { + const boardType = event.metadata.boardType; + const layoutId = parseInt(event.metadata.layoutId || '0', 10); + if (!boardType || !layoutId) return; + + const followerRecipients = await resolveClimbCreatedFollowerRecipients(event.actorId); + const subscriberRecipients = await resolveClimbCreatedSubscriptionRecipients( + boardType, + layoutId, + event.actorId, + ); + const followerIds = new Set(followerRecipients.map((r) => r.recipientId)); + const recipients = [ + ...followerRecipients, + ...subscriberRecipients.filter((r) => !followerIds.has(r.recipientId)), + ].filter((r) => r.recipientId !== event.actorId); + + if (recipients.length === 0) { + await fanoutNewClimbFeedItems(event); + return; + } + + const actorRows = await db + .select({ + name: dbSchema.users.name, + image: dbSchema.users.image, + displayName: dbSchema.userProfiles.displayName, + avatarUrl: dbSchema.userProfiles.avatarUrl, + }) + .from(dbSchema.users) + .leftJoin(dbSchema.userProfiles, eq(dbSchema.users.id, dbSchema.userProfiles.userId)) + .where(eq(dbSchema.users.id, event.actorId)) + .limit(1); + const actor = actorRows[0]; + + for (const recipient of recipients) { + const uuid = crypto.randomUUID(); + await db + .insert(dbSchema.notifications) + .values({ + uuid, + recipientId: recipient.recipientId, + actorId: event.actorId, + type: recipient.notificationType, + entityType: 'climb', + entityId: event.entityId, + }); + + pubsub.publishNotificationEvent(recipient.recipientId, { + notification: { + uuid, + type: recipient.notificationType as NotificationType, + actorId: event.actorId, + actorDisplayName: actor?.displayName || actor?.name || undefined, + actorAvatarUrl: actor?.avatarUrl || actor?.image || undefined, + entityType: 'climb', + entityId: event.entityId, + climbName: event.metadata.climbName || undefined, + climbUuid: event.entityId, + boardType, + isRead: false, + createdAt: new Date().toISOString(), + }, + }); + } + + await fanoutNewClimbFeedItems(event); + + const [climb] = await db + .select({ + uuid: dbSchema.boardClimbs.uuid, + name: dbSchema.boardClimbs.name, + layoutId: dbSchema.boardClimbs.layoutId, + angle: dbSchema.boardClimbs.angle, + frames: dbSchema.boardClimbs.frames, + createdAt: dbSchema.boardClimbs.createdAt, + setterDisplayName: dbSchema.userProfiles.displayName, + setterAvatarUrl: dbSchema.userProfiles.avatarUrl, + difficultyName: dbSchema.boardDifficultyGrades.boulderName, + }) + .from(dbSchema.boardClimbs) + .leftJoin(dbSchema.users, eq(dbSchema.boardClimbs.userId, dbSchema.users.id)) + .leftJoin(dbSchema.userProfiles, eq(dbSchema.users.id, dbSchema.userProfiles.userId)) + .leftJoin( + dbSchema.boardClimbStats, + and( + eq(dbSchema.boardClimbStats.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardClimbStats.climbUuid, dbSchema.boardClimbs.uuid), + eq(dbSchema.boardClimbStats.angle, dbSchema.boardClimbs.angle), + ), + ) + .leftJoin( + dbSchema.boardDifficultyGrades, + and( + eq(dbSchema.boardDifficultyGrades.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardDifficultyGrades.difficulty, dbSchema.boardClimbStats.displayDifficulty), + ), + ) + .where( + and( + eq(dbSchema.boardClimbs.uuid, event.entityId), + eq(dbSchema.boardClimbs.boardType, boardType), + ), + ) + .limit(1); + + if (climb) { + const channelKey = `${boardType}:${climb.layoutId}`; + pubsub.publishNewClimbEvent(channelKey, { + climb: { + uuid: climb.uuid, + name: climb.name ?? event.metadata.climbName, + boardType, + layoutId: climb.layoutId, + setterDisplayName: climb.setterDisplayName ?? actor?.displayName ?? actor?.name ?? undefined, + setterAvatarUrl: climb.setterAvatarUrl ?? actor?.avatarUrl ?? actor?.image ?? undefined, + angle: climb.angle ?? null, + frames: climb.frames ?? null, + difficultyName: climb.difficultyName ?? event.metadata.difficultyName ?? null, + createdAt: climb.createdAt ?? new Date().toISOString(), + }, + }); + } + return; + } case 'ascent.logged': { await fanoutFeedItems(event); // No notification for ascent.logged - it's feed-only diff --git a/packages/backend/src/events/notification-worker.ts b/packages/backend/src/events/notification-worker.ts index edc472788..d36a86363 100644 --- a/packages/backend/src/events/notification-worker.ts +++ b/packages/backend/src/events/notification-worker.ts @@ -13,8 +13,10 @@ import { resolveProposalApprovalRecipients, resolveProposalRejectionRecipients, resolveProposalCreatedRecipients, + resolveClimbCreatedFollowerRecipients, + resolveClimbCreatedSubscriptionRecipients, } from './recipient-resolution'; -import { fanoutFeedItems } from './feed-fanout'; +import { fanoutFeedItems, fanoutNewClimbFeedItems } from './feed-fanout'; import crypto from 'crypto'; export class NotificationWorker { @@ -59,6 +61,9 @@ export class NotificationWorker { case 'proposal.created': await this.handleProposalCreated(event); break; + case 'climb.created': + await this.handleClimbCreated(event); + break; default: break; } @@ -236,6 +241,101 @@ export class NotificationWorker { } } + /** + * Handle climb.created events: notify followers and layout subscribers, + * fan out feed items, and publish realtime new-climb events. + */ + private async handleClimbCreated(event: SocialEvent): Promise { + const boardType = event.metadata.boardType; + const layoutId = parseInt(event.metadata.layoutId || '0', 10); + const climbName = event.metadata.climbName || ''; + + if (!boardType || !layoutId) return; + + const followerRecipients = await resolveClimbCreatedFollowerRecipients(event.actorId); + const subscriberRecipients = await resolveClimbCreatedSubscriptionRecipients( + boardType, + layoutId, + event.actorId, + ); + + const followerIds = new Set(followerRecipients.map((r) => r.recipientId)); + const allRecipients = [ + ...followerRecipients, + ...subscriberRecipients.filter((r) => !followerIds.has(r.recipientId)), + ]; + + for (const recipient of allRecipients) { + await this.createAndPublishNotification( + recipient.recipientId, + event.actorId, + recipient.notificationType, + 'climb', + event.entityId, + ); + } + + // Fan out feed items to followers only (not global subscribers) + await fanoutNewClimbFeedItems(event); + + // Publish realtime new climb event to the board+layout channel + const [climb] = await db + .select({ + uuid: dbSchema.boardClimbs.uuid, + name: dbSchema.boardClimbs.name, + layoutId: dbSchema.boardClimbs.layoutId, + angle: dbSchema.boardClimbs.angle, + frames: dbSchema.boardClimbs.frames, + createdAt: dbSchema.boardClimbs.createdAt, + setterDisplayName: dbSchema.userProfiles.displayName, + setterAvatarUrl: dbSchema.userProfiles.avatarUrl, + difficultyName: dbSchema.boardDifficultyGrades.boulderName, + }) + .from(dbSchema.boardClimbs) + .leftJoin(dbSchema.users, eq(dbSchema.boardClimbs.userId, dbSchema.users.id)) + .leftJoin(dbSchema.userProfiles, eq(dbSchema.users.id, dbSchema.userProfiles.userId)) + .leftJoin( + dbSchema.boardClimbStats, + and( + eq(dbSchema.boardClimbStats.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardClimbStats.climbUuid, dbSchema.boardClimbs.uuid), + eq(dbSchema.boardClimbStats.angle, dbSchema.boardClimbs.angle), + ), + ) + .leftJoin( + dbSchema.boardDifficultyGrades, + and( + eq(dbSchema.boardDifficultyGrades.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardDifficultyGrades.difficulty, dbSchema.boardClimbStats.displayDifficulty), + ), + ) + .where( + and( + eq(dbSchema.boardClimbs.uuid, event.entityId), + eq(dbSchema.boardClimbs.boardType, boardType), + ), + ) + .limit(1); + + if (climb) { + const channelKey = `${boardType}:${climb.layoutId}`; + pubsub.publishNewClimbEvent(channelKey, { + climb: { + uuid: climb.uuid, + name: climb.name ?? climbName, + boardType, + layoutId: climb.layoutId, + setterDisplayName: climb.setterDisplayName ?? event.metadata.setterDisplayName ?? null, + setterAvatarUrl: climb.setterAvatarUrl ?? event.metadata.setterAvatarUrl ?? null, + angle: climb.angle ?? null, + frames: climb.frames ?? null, + difficultyName: climb.difficultyName ?? event.metadata.difficultyName ?? null, + createdAt: climb.createdAt ?? new Date().toISOString(), + }, + }); + } + } + private async isDuplicate( actorId: string, recipientId: string, @@ -336,6 +436,26 @@ export class NotificationWorker { } } + let climbName: string | undefined; + let climbUuid: string | undefined; + let climbBoardType: string | undefined; + + if (type === 'new_climb' || type === 'new_climb_global') { + const [climb] = await db + .select({ + name: dbSchema.boardClimbs.name, + boardType: dbSchema.boardClimbs.boardType, + }) + .from(dbSchema.boardClimbs) + .where(eq(dbSchema.boardClimbs.uuid, entityId)) + .limit(1); + if (climb) { + climbName = climb.name ?? undefined; + climbUuid = entityId; + climbBoardType = climb.boardType; + } + } + return { uuid, type, @@ -345,9 +465,9 @@ export class NotificationWorker { entityType: entityType as dbSchema.SocialEntityType, entityId, commentBody, - climbName: undefined, - climbUuid: undefined, - boardType: undefined, + climbName, + climbUuid, + boardType: climbBoardType, isRead: false, createdAt: new Date().toISOString(), }; diff --git a/packages/backend/src/events/recipient-resolution.ts b/packages/backend/src/events/recipient-resolution.ts index 8baa3caf3..6cd1507d4 100644 --- a/packages/backend/src/events/recipient-resolution.ts +++ b/packages/backend/src/events/recipient-resolution.ts @@ -235,3 +235,46 @@ export function resolveFollowRecipient( notificationType: 'new_follower', }; } + +/** + * Resolve recipients when a user creates a climb: all followers of the setter. + */ +export async function resolveClimbCreatedFollowerRecipients( + setterId: string, +): Promise { + const followers = await db + .select({ followerId: dbSchema.userFollows.followerId }) + .from(dbSchema.userFollows) + .where(eq(dbSchema.userFollows.followingId, setterId)); + + return followers.map((f) => ({ + recipientId: f.followerId, + notificationType: 'new_climb', + })); +} + +/** + * Resolve recipients subscribed to a board type + layout for new climb notifications. + */ +export async function resolveClimbCreatedSubscriptionRecipients( + boardType: string, + layoutId: number, + excludeUserId?: string, +): Promise { + const rows = await db + .select({ userId: dbSchema.newClimbSubscriptions.userId }) + .from(dbSchema.newClimbSubscriptions) + .where( + and( + eq(dbSchema.newClimbSubscriptions.boardType, boardType), + eq(dbSchema.newClimbSubscriptions.layoutId, layoutId), + ), + ); + + return rows + .filter((r) => r.userId !== excludeUserId) + .map((r) => ({ + recipientId: r.userId, + notificationType: 'new_climb_global', + })); +} diff --git a/packages/backend/src/graphql/resolvers/climbs/mutations.ts b/packages/backend/src/graphql/resolvers/climbs/mutations.ts new file mode 100644 index 000000000..abeb9aa30 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/climbs/mutations.ts @@ -0,0 +1,242 @@ +import crypto from 'crypto'; +import { and, eq, sql } from 'drizzle-orm'; +import type { ConnectionContext, SaveClimbResult } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; +import { db } from '../../../db/client'; +import * as dbSchema from '@boardsesh/db/schema'; +import { UNIFIED_TABLES, isValidBoardName } from '../../../db/queries/util/table-select'; +import { publishSocialEvent } from '../../../events'; +import { requireAuthenticated, applyRateLimit, validateInput } from '../shared/helpers'; +import { + SaveClimbInputSchema, + SaveMoonBoardClimbInputSchema, +} from '../../../validation/schemas'; + +type SaveClimbArgs = { input: unknown }; + +function generateClimbUuid(): string { + // Match Aurora-style uppercase UUID without dashes + return crypto.randomUUID().replace(/-/g, '').toUpperCase(); +} + +async function getUserProfile(userId: string) { + const [user] = await db + .select({ + name: dbSchema.users.name, + image: dbSchema.users.image, + displayName: dbSchema.userProfiles.displayName, + avatarUrl: dbSchema.userProfiles.avatarUrl, + }) + .from(dbSchema.users) + .leftJoin(dbSchema.userProfiles, eq(dbSchema.users.id, dbSchema.userProfiles.userId)) + .where(eq(dbSchema.users.id, userId)) + .limit(1); + + return { + displayName: user?.displayName || user?.name || '', + name: user?.name || '', + avatarUrl: user?.avatarUrl || user?.image || undefined, + }; +} + +function encodeMoonBoardHoldsToFrames(holds: { start: string[]; hand: string[]; finish: string[] }): string { + const START = 42; + const HAND = 43; + const FINISH = 44; + + const coordinateToHoldId = (coord: string): number => { + // Coord format: e.g., "A1" -> column letter + row number + const colIndex = coord[0].toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0); + const row = parseInt(coord.slice(1), 10); + const NUM_COLUMNS = 11; // MoonBoard grid: 11 columns x 18 rows + return (row - 1) * NUM_COLUMNS + colIndex + 1; + }; + + const parts: string[] = []; + holds.start.forEach((coord) => parts.push(`p${coordinateToHoldId(coord)}r${START}`)); + holds.hand.forEach((coord) => parts.push(`p${coordinateToHoldId(coord)}r${HAND}`)); + holds.finish.forEach((coord) => parts.push(`p${coordinateToHoldId(coord)}r${FINISH}`)); + return parts.join(''); +} + +async function resolveDifficultyId(boardType: string, grade?: string | null): Promise { + if (!grade) return null; + const fontPart = grade.split('/')[0].trim().toLowerCase(); + + const [row] = await db + .select({ difficulty: dbSchema.boardDifficultyGrades.difficulty }) + .from(dbSchema.boardDifficultyGrades) + .where( + and( + eq(dbSchema.boardDifficultyGrades.boardType, boardType), + sql`LOWER(${dbSchema.boardDifficultyGrades.boulderName}) = ${fontPart}` + ) + ) + .limit(1); + + return row?.difficulty ?? null; +} + +export const climbMutations = { + /** + * Save a new climb for Aurora-style boards (kilter/tension) via GraphQL. + * Persists to the unified board_climbs table and publishes a climb.created event. + */ + saveClimb: async ( + _: unknown, + { input }: SaveClimbArgs, + ctx: ConnectionContext + ): Promise => { + requireAuthenticated(ctx); + applyRateLimit(ctx, 10); + + const validated = validateInput(SaveClimbInputSchema, input, 'input'); + + if (!isValidBoardName(validated.boardType)) { + throw new Error(`Invalid board type: ${validated.boardType}. Must be one of ${SUPPORTED_BOARDS.join(', ')}`); + } + + const uuid = generateClimbUuid(); + const now = new Date().toISOString(); + const { displayName, name, avatarUrl } = await getUserProfile(ctx.userId!); + const preferredSetter = displayName || name || null; + + await db.insert(UNIFIED_TABLES.climbs).values({ + boardType: validated.boardType, + uuid, + layoutId: validated.layoutId, + userId: ctx.userId!, + setterId: null, + setterUsername: preferredSetter, + name: validated.name, + description: validated.description ?? '', + angle: validated.angle, + framesCount: validated.framesCount ?? 1, + framesPace: validated.framesPace ?? 0, + frames: validated.frames, + isDraft: validated.isDraft, + isListed: false, + createdAt: now, + synced: false, + syncError: null, + }); + + await publishSocialEvent({ + type: 'climb.created', + actorId: ctx.userId!, + entityType: 'climb', + entityId: uuid, + timestamp: Date.now(), + metadata: { + boardType: validated.boardType, + layoutId: String(validated.layoutId), + climbName: validated.name, + climbUuid: uuid, + angle: String(validated.angle), + frames: validated.frames, + setterDisplayName: preferredSetter || '', + setterAvatarUrl: avatarUrl || '', + }, + }); + + return { uuid, synced: false }; + }, + + /** + * Save a new MoonBoard climb via GraphQL. + * Encodes holds to frames, optionally stores grade stats, and publishes climb.created. + */ + saveMoonBoardClimb: async ( + _: unknown, + { input }: SaveClimbArgs, + ctx: ConnectionContext + ): Promise => { + requireAuthenticated(ctx); + applyRateLimit(ctx, 10); + + const validated = validateInput(SaveMoonBoardClimbInputSchema, input, 'input'); + + if (validated.boardType !== 'moonboard') { + throw new Error('saveMoonBoardClimb is only supported for boardType=moonboard'); + } + + const uuid = generateClimbUuid(); + const now = new Date().toISOString(); + const { displayName, name, avatarUrl } = await getUserProfile(ctx.userId!); + const preferredSetter = validated.setter || displayName || name || null; + + const frames = encodeMoonBoardHoldsToFrames(validated.holds as { start: string[]; hand: string[]; finish: string[] }); + + await db.insert(UNIFIED_TABLES.climbs).values({ + boardType: validated.boardType, + uuid, + layoutId: validated.layoutId, + userId: ctx.userId!, + setterId: null, + setterUsername: preferredSetter, + name: validated.name, + description: validated.description ?? '', + angle: validated.angle, + framesCount: 1, + framesPace: 0, + frames, + isDraft: validated.isDraft ?? false, + isListed: false, + createdAt: now, + synced: false, + syncError: null, + }); + + // Optional grade stats + const difficultyId = await resolveDifficultyId(validated.boardType, validated.userGrade); + if (difficultyId !== null) { + await db + .insert(dbSchema.boardClimbStats) + .values({ + boardType: validated.boardType, + climbUuid: uuid, + angle: validated.angle, + displayDifficulty: difficultyId, + benchmarkDifficulty: validated.isBenchmark ? difficultyId : null, + ascensionistCount: 0, + difficultyAverage: difficultyId, + qualityAverage: null, + faUsername: validated.setter || null, + faAt: null, + }) + .onConflictDoUpdate({ + target: [ + dbSchema.boardClimbStats.boardType, + dbSchema.boardClimbStats.climbUuid, + dbSchema.boardClimbStats.angle, + ], + set: { + displayDifficulty: difficultyId, + benchmarkDifficulty: validated.isBenchmark ? difficultyId : null, + difficultyAverage: difficultyId, + }, + }); + } + + await publishSocialEvent({ + type: 'climb.created', + actorId: ctx.userId!, + entityType: 'climb', + entityId: uuid, + timestamp: Date.now(), + metadata: { + boardType: validated.boardType, + layoutId: String(validated.layoutId), + climbName: validated.name, + climbUuid: uuid, + angle: String(validated.angle), + frames, + setterDisplayName: preferredSetter || '', + setterAvatarUrl: avatarUrl || '', + difficultyName: validated.userGrade || '', + }, + }); + + return { uuid, synced: false }; + }, +}; diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index dc1fdceed..99541b30b 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -7,6 +7,7 @@ import { tickMutations } from './ticks/mutations'; import { userQueries } from './users/queries'; import { userMutations } from './users/mutations'; import { climbQueries } from './climbs/queries'; +import { climbMutations } from './climbs/mutations'; import { climbFieldResolvers } from './climbs/field-resolvers'; import { favoriteQueries } from './favorites/queries'; import { favoriteClimbsQuery } from './favorites/favorite-climbs-query'; @@ -35,6 +36,8 @@ import { socialCommentSubscriptions } from './social/comment-subscriptions'; import { socialProposalQueries, socialProposalMutations } from './social/proposals'; import { socialRoleQueries, socialRoleMutations } from './social/roles'; import { socialCommunitySettingsQueries, socialCommunitySettingsMutations } from './social/community-settings'; +import { newClimbSubscriptionResolvers } from './social/new-climb-subscriptions'; +import { newClimbFeedSubscription } from './social/new-climb-feed-subscription'; export const resolvers = { // Scalar types @@ -62,12 +65,14 @@ export const resolvers = { ...socialProposalQueries, ...socialRoleQueries, ...socialCommunitySettingsQueries, + ...newClimbSubscriptionResolvers.Query, }, Mutation: { ...sessionMutations, ...queueMutations, ...tickMutations, + ...climbMutations, ...userMutations, ...favoriteMutations, ...playlistMutations, @@ -80,6 +85,7 @@ export const resolvers = { ...socialProposalMutations, ...socialRoleMutations, ...socialCommunitySettingsMutations, + ...newClimbSubscriptionResolvers.Mutation, }, Subscription: { @@ -88,6 +94,7 @@ export const resolvers = { ...controllerSubscriptions, ...socialNotificationSubscriptions, ...socialCommentSubscriptions, + ...newClimbFeedSubscription, }, // Field-level resolvers diff --git a/packages/backend/src/graphql/resolvers/social/new-climb-feed-subscription.ts b/packages/backend/src/graphql/resolvers/social/new-climb-feed-subscription.ts new file mode 100644 index 000000000..26c6148fd --- /dev/null +++ b/packages/backend/src/graphql/resolvers/social/new-climb-feed-subscription.ts @@ -0,0 +1,31 @@ +import type { ConnectionContext, NewClimbCreatedEvent } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; +import { pubsub } from '../../../pubsub/index'; +import { createAsyncIterator } from '../shared/async-iterators'; + +export const newClimbFeedSubscription = { + newClimbCreated: { + subscribe: async function* ( + _: unknown, + { boardType, layoutId }: { boardType: string; layoutId: number }, + _ctx: ConnectionContext, + ) { + if (!SUPPORTED_BOARDS.includes(boardType as typeof SUPPORTED_BOARDS[number])) { + throw new Error(`Invalid boardType: ${boardType}`); + } + if (!Number.isInteger(layoutId) || layoutId <= 0) { + throw new Error('layoutId must be a positive integer'); + } + + const channelKey = `${boardType}:${layoutId}`; + + const asyncIterator = await createAsyncIterator((push) => { + return pubsub.subscribeNewClimbs(channelKey, push); + }); + + for await (const event of asyncIterator) { + yield { newClimbCreated: event }; + } + }, + }, +}; diff --git a/packages/backend/src/graphql/resolvers/social/new-climb-subscriptions.ts b/packages/backend/src/graphql/resolvers/social/new-climb-subscriptions.ts new file mode 100644 index 000000000..5aa7569fb --- /dev/null +++ b/packages/backend/src/graphql/resolvers/social/new-climb-subscriptions.ts @@ -0,0 +1,170 @@ +import { and, eq, desc, sql } from 'drizzle-orm'; +import type { + ConnectionContext, + NewClimbFeedInput, + NewClimbFeedResult, + NewClimbSubscription, + NewClimbSubscriptionInput, +} from '@boardsesh/shared-schema'; +import { db } from '../../../db/client'; +import * as dbSchema from '@boardsesh/db/schema'; +import { requireAuthenticated, applyRateLimit, validateInput } from '../shared/helpers'; +import { + NewClimbFeedInputSchema, + NewClimbSubscriptionInputSchema, +} from '../../../validation/schemas'; + +export const newClimbSubscriptionResolvers = { + Query: { + /** + * Public feed of newly created climbs for a board type + layout. + * Offset-based pagination for simplicity. + */ + newClimbFeed: async ( + _: unknown, + { input }: { input: NewClimbFeedInput }, + ): Promise => { + const validated = validateInput(NewClimbFeedInputSchema, input, 'input'); + const limit = validated.limit ?? 20; + const offset = validated.offset ?? 0; + + 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, + frames: dbSchema.boardClimbs.frames, + createdAt: dbSchema.boardClimbs.createdAt, + setterDisplayName: sql`COALESCE(${dbSchema.userProfiles.displayName}, ${dbSchema.users.name}, ${dbSchema.boardClimbs.setterUsername})`, + setterAvatarUrl: sql`COALESCE(${dbSchema.userProfiles.avatarUrl}, ${dbSchema.users.image})`, + difficultyName: dbSchema.boardDifficultyGrades.boulderName, + }) + .from(dbSchema.boardClimbs) + .leftJoin(dbSchema.users, eq(dbSchema.boardClimbs.userId, dbSchema.users.id)) + .leftJoin(dbSchema.userProfiles, eq(dbSchema.users.id, dbSchema.userProfiles.userId)) + .leftJoin( + dbSchema.boardClimbStats, + and( + eq(dbSchema.boardClimbStats.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardClimbStats.climbUuid, dbSchema.boardClimbs.uuid), + eq(dbSchema.boardClimbStats.angle, dbSchema.boardClimbs.angle), + ), + ) + .leftJoin( + dbSchema.boardDifficultyGrades, + and( + eq(dbSchema.boardDifficultyGrades.boardType, dbSchema.boardClimbs.boardType), + eq(dbSchema.boardDifficultyGrades.difficulty, dbSchema.boardClimbStats.displayDifficulty), + ), + ) + .where( + and( + eq(dbSchema.boardClimbs.boardType, validated.boardType), + eq(dbSchema.boardClimbs.layoutId, validated.layoutId), + ), + ) + .orderBy(desc(dbSchema.boardClimbs.createdAt)) + .limit(limit) + .offset(offset); + + const [{ total }] = await db + .select({ total: sql`count(*)` }) + .from(dbSchema.boardClimbs) + .where( + and( + eq(dbSchema.boardClimbs.boardType, validated.boardType), + eq(dbSchema.boardClimbs.layoutId, validated.layoutId), + ), + ); + + const totalCount = Number(total) || 0; + const items = climbs.map((c) => ({ + uuid: c.uuid, + name: c.name ?? '', + boardType: c.boardType, + layoutId: c.layoutId, + setterDisplayName: c.setterDisplayName ?? null, + setterAvatarUrl: c.setterAvatarUrl ?? null, + angle: c.angle ?? null, + frames: c.frames ?? null, + difficultyName: c.difficultyName ?? null, + createdAt: c.createdAt ?? new Date().toISOString(), + })); + + return { + items, + totalCount, + hasMore: offset + items.length < totalCount, + }; + }, + + /** + * Authenticated user's subscriptions to new climbs. + */ + myNewClimbSubscriptions: async ( + _: unknown, + _args: unknown, + ctx: ConnectionContext, + ): Promise => { + requireAuthenticated(ctx); + const rows = await db + .select() + .from(dbSchema.newClimbSubscriptions) + .where(eq(dbSchema.newClimbSubscriptions.userId, ctx.userId!)); + + return rows.map((r) => ({ + id: String(r.id), + boardType: r.boardType, + layoutId: r.layoutId, + createdAt: r.createdAt?.toISOString?.() ?? new Date().toISOString(), + })); + }, + }, + + Mutation: { + subscribeNewClimbs: async ( + _: unknown, + { input }: { input: NewClimbSubscriptionInput }, + ctx: ConnectionContext + ): Promise => { + requireAuthenticated(ctx); + applyRateLimit(ctx, 20); + const validated = validateInput(NewClimbSubscriptionInputSchema, input, 'input'); + + await db + .insert(dbSchema.newClimbSubscriptions) + .values({ + userId: ctx.userId!, + boardType: validated.boardType, + layoutId: validated.layoutId, + }) + .onConflictDoNothing(); + + return true; + }, + + unsubscribeNewClimbs: async ( + _: unknown, + { input }: { input: NewClimbSubscriptionInput }, + ctx: ConnectionContext + ): Promise => { + requireAuthenticated(ctx); + applyRateLimit(ctx, 20); + const validated = validateInput(NewClimbSubscriptionInputSchema, input, 'input'); + + await db + .delete(dbSchema.newClimbSubscriptions) + .where( + and( + eq(dbSchema.newClimbSubscriptions.userId, ctx.userId!), + eq(dbSchema.newClimbSubscriptions.boardType, validated.boardType), + eq(dbSchema.newClimbSubscriptions.layoutId, validated.layoutId), + ), + ); + + return true; + }, + }, +}; diff --git a/packages/backend/src/pubsub/index.ts b/packages/backend/src/pubsub/index.ts index f5b343a21..026e30d00 100644 --- a/packages/backend/src/pubsub/index.ts +++ b/packages/backend/src/pubsub/index.ts @@ -1,4 +1,4 @@ -import type { QueueEvent, SessionEvent, NotificationEvent, CommentEvent } from '@boardsesh/shared-schema'; +import type { QueueEvent, SessionEvent, NotificationEvent, CommentEvent, NewClimbCreatedEvent } from '@boardsesh/shared-schema'; import { redisClientManager } from '../redis/client'; import { createRedisPubSubAdapter, type RedisPubSubAdapter } from './redis-adapter'; @@ -6,6 +6,7 @@ type QueueSubscriber = (event: QueueEvent) => void; type SessionSubscriber = (event: SessionEvent) => void; type NotificationSubscriber = (event: NotificationEvent) => void; type CommentSubscriber = (event: CommentEvent) => void; +type NewClimbSubscriber = (event: NewClimbCreatedEvent) => void; // Event buffer configuration (Phase 2: Delta sync) const EVENT_BUFFER_SIZE = 100; // Store last 100 events per session @@ -28,6 +29,7 @@ class PubSub { private sessionSubscribers: Map> = new Map(); private notificationSubscribers: Map> = new Map(); private commentSubscribers: Map> = new Map(); + private newClimbSubscribers: Map> = new Map(); private redisAdapter: RedisPubSubAdapter | null = null; private initialized = false; private redisRequired = false; @@ -97,6 +99,10 @@ class PubSub { this.redisAdapter.onCommentMessage((entityKey, event) => { this.dispatchToLocalCommentSubscribers(entityKey, event); }); + + this.redisAdapter.onNewClimbMessage((channelKey, event) => { + this.dispatchToLocalNewClimbSubscribers(channelKey, event); + }); } /** @@ -483,6 +489,74 @@ class PubSub { } } + /** + * Subscribe to new climb events for a board type + layout combination. + * @param channelKey format: `${boardType}:${layoutId}` + */ + async subscribeNewClimbs(channelKey: string, callback: NewClimbSubscriber): Promise<() => void> { + this.ensureRedisIfRequired(); + + const isFirstSubscriber = !this.newClimbSubscribers.has(channelKey); + + if (!this.newClimbSubscribers.has(channelKey)) { + this.newClimbSubscribers.set(channelKey, new Set()); + } + this.newClimbSubscribers.get(channelKey)!.add(callback); + + if (isFirstSubscriber && this.redisAdapter) { + try { + await this.redisAdapter.subscribeNewClimbChannel(channelKey); + } catch (error) { + console.error(`[PubSub] Failed to subscribe to Redis new climb channel: ${error}`); + this.newClimbSubscribers.get(channelKey)?.delete(callback); + if (this.newClimbSubscribers.get(channelKey)?.size === 0) { + this.newClimbSubscribers.delete(channelKey); + } + if (this.redisRequired) { + throw error; + } + } + } + + return () => { + this.newClimbSubscribers.get(channelKey)?.delete(callback); + if (this.newClimbSubscribers.get(channelKey)?.size === 0) { + this.newClimbSubscribers.delete(channelKey); + if (this.redisAdapter) { + this.redisAdapter.unsubscribeNewClimbChannel(channelKey).catch((error) => { + console.error(`[PubSub] Failed to unsubscribe from Redis new climb channel: ${error}`); + }); + } + } + }; + } + + /** + * Publish a new climb event to subscribers. + */ + publishNewClimbEvent(channelKey: string, event: NewClimbCreatedEvent): void { + this.dispatchToLocalNewClimbSubscribers(channelKey, event); + + if (this.redisAdapter) { + this.redisAdapter.publishNewClimbEvent(channelKey, event).catch((error) => { + console.error('[PubSub] Redis new climb publish failed:', error); + }); + } + } + + private dispatchToLocalNewClimbSubscribers(channelKey: string, event: NewClimbCreatedEvent): void { + const subscribers = this.newClimbSubscribers.get(channelKey); + if (subscribers) { + for (const callback of subscribers) { + try { + callback(event); + } catch (error) { + console.error('Error in new climb subscriber:', error); + } + } + } + } + /** * Ensure Redis is connected if it's required. * @throws If Redis is required but not connected diff --git a/packages/backend/src/pubsub/redis-adapter.ts b/packages/backend/src/pubsub/redis-adapter.ts index 7516372f9..bc885bc27 100644 --- a/packages/backend/src/pubsub/redis-adapter.ts +++ b/packages/backend/src/pubsub/redis-adapter.ts @@ -1,4 +1,4 @@ -import type { QueueEvent, SessionEvent, NotificationEvent, CommentEvent } from '@boardsesh/shared-schema'; +import type { QueueEvent, SessionEvent, NotificationEvent, CommentEvent, NewClimbCreatedEvent } from '@boardsesh/shared-schema'; import type Redis from 'ioredis'; import { v4 as uuidv4 } from 'uuid'; @@ -7,10 +7,11 @@ const QUEUE_CHANNEL_PREFIX = 'boardsesh:queue:'; const SESSION_CHANNEL_PREFIX = 'boardsesh:session:'; const NOTIFICATION_CHANNEL_PREFIX = 'boardsesh:notifications:'; const COMMENT_CHANNEL_PREFIX = 'boardsesh:comments:'; +const NEW_CLIMB_CHANNEL_PREFIX = 'boardsesh:new-climbs:'; interface RedisMessage { instanceId: string; - event: QueueEvent | SessionEvent | NotificationEvent | CommentEvent; + event: QueueEvent | SessionEvent | NotificationEvent | CommentEvent | NewClimbCreatedEvent; timestamp: number; } @@ -19,18 +20,22 @@ export interface RedisPubSubAdapter { publishSessionEvent(sessionId: string, event: SessionEvent): Promise; publishNotificationEvent(userId: string, event: NotificationEvent): Promise; publishCommentEvent(entityKey: string, event: CommentEvent): Promise; + publishNewClimbEvent(channelKey: string, event: NewClimbCreatedEvent): Promise; subscribeQueueChannel(sessionId: string): Promise; subscribeSessionChannel(sessionId: string): Promise; subscribeNotificationChannel(userId: string): Promise; subscribeCommentChannel(entityKey: string): Promise; + subscribeNewClimbChannel(channelKey: string): Promise; unsubscribeQueueChannel(sessionId: string): Promise; unsubscribeSessionChannel(sessionId: string): Promise; unsubscribeNotificationChannel(userId: string): Promise; unsubscribeCommentChannel(entityKey: string): Promise; + unsubscribeNewClimbChannel(channelKey: string): Promise; onQueueMessage(callback: (sessionId: string, event: QueueEvent) => void): void; onSessionMessage(callback: (sessionId: string, event: SessionEvent) => void): void; onNotificationMessage(callback: (userId: string, event: NotificationEvent) => void): void; onCommentMessage(callback: (entityKey: string, event: CommentEvent) => void): void; + onNewClimbMessage(callback: (channelKey: string, event: NewClimbCreatedEvent) => void): void; getInstanceId(): string; } @@ -43,11 +48,13 @@ export function createRedisPubSubAdapter( const subscribedSessionChannels = new Set(); const subscribedNotificationChannels = new Set(); const subscribedCommentChannels = new Set(); + const subscribedNewClimbChannels = new Set(); let queueMessageCallback: ((sessionId: string, event: QueueEvent) => void) | null = null; let sessionMessageCallback: ((sessionId: string, event: SessionEvent) => void) | null = null; let notificationMessageCallback: ((userId: string, event: NotificationEvent) => void) | null = null; let commentMessageCallback: ((entityKey: string, event: CommentEvent) => void) | null = null; + let newClimbMessageCallback: ((channelKey: string, event: NewClimbCreatedEvent) => void) | null = null; // Set up message handler subscriber.on('message', (channel: string, message: string) => { @@ -81,6 +88,11 @@ export function createRedisPubSubAdapter( if (commentMessageCallback) { commentMessageCallback(entityKey, parsed.event as CommentEvent); } + } else if (channel.startsWith(NEW_CLIMB_CHANNEL_PREFIX)) { + const channelKey = channel.slice(NEW_CLIMB_CHANNEL_PREFIX.length); + if (newClimbMessageCallback) { + newClimbMessageCallback(channelKey, parsed.event as NewClimbCreatedEvent); + } } } catch (error) { console.error('[Redis] Failed to parse message:', error); @@ -130,6 +142,16 @@ export function createRedisPubSubAdapter( await publisher.publish(channel, JSON.stringify(message)); }, + async publishNewClimbEvent(channelKey: string, event: NewClimbCreatedEvent): Promise { + const channel = `${NEW_CLIMB_CHANNEL_PREFIX}${channelKey}`; + const message: RedisMessage = { + instanceId, + event, + timestamp: Date.now(), + }; + await publisher.publish(channel, JSON.stringify(message)); + }, + async subscribeQueueChannel(sessionId: string): Promise { const channel = `${QUEUE_CHANNEL_PREFIX}${sessionId}`; if (subscribedQueueChannels.has(channel)) { @@ -188,6 +210,16 @@ export function createRedisPubSubAdapter( subscribedCommentChannels.add(channel); }, + async subscribeNewClimbChannel(channelKey: string): Promise { + const channel = `${NEW_CLIMB_CHANNEL_PREFIX}${channelKey}`; + if (subscribedNewClimbChannels.has(channel)) { + return; + } + await subscriber.subscribe(channel); + subscribedNewClimbChannels.add(channel); + console.log(`[Redis] Subscribed to new climb channel: ${channelKey}`); + }, + async unsubscribeNotificationChannel(userId: string): Promise { const channel = `${NOTIFICATION_CHANNEL_PREFIX}${userId}`; if (!subscribedNotificationChannels.has(channel)) { @@ -206,6 +238,16 @@ export function createRedisPubSubAdapter( subscribedCommentChannels.delete(channel); }, + async unsubscribeNewClimbChannel(channelKey: string): Promise { + const channel = `${NEW_CLIMB_CHANNEL_PREFIX}${channelKey}`; + if (!subscribedNewClimbChannels.has(channel)) { + return; + } + await subscriber.unsubscribe(channel); + subscribedNewClimbChannels.delete(channel); + console.log(`[Redis] Unsubscribed from new climb channel: ${channelKey}`); + }, + onQueueMessage(callback: (sessionId: string, event: QueueEvent) => void): void { queueMessageCallback = callback; }, @@ -222,6 +264,10 @@ export function createRedisPubSubAdapter( commentMessageCallback = callback; }, + onNewClimbMessage(callback: (channelKey: string, event: NewClimbCreatedEvent) => void): void { + newClimbMessageCallback = callback; + }, + getInstanceId(): string { return instanceId; }, diff --git a/packages/backend/src/validation/schemas.ts b/packages/backend/src/validation/schemas.ts index a371c0416..c1c477e2d 100644 --- a/packages/backend/src/validation/schemas.ts +++ b/packages/backend/src/validation/schemas.ts @@ -218,6 +218,51 @@ export const SaveAuroraCredentialInputSchema = z.object({ password: z.string().min(1, 'Password cannot be empty').max(100), }); +// ============================================ +// New Climb Feed & Subscriptions Schemas +// ============================================ + +export const NewClimbSubscriptionInputSchema = z.object({ + boardType: BoardNameSchema, + layoutId: z.number().int().positive('Layout ID must be positive'), +}); + +export const NewClimbFeedInputSchema = z.object({ + boardType: BoardNameSchema, + layoutId: z.number().int().positive('Layout ID must be positive'), + limit: z.number().int().min(1).max(100).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const SaveClimbInputSchema = z.object({ + boardType: BoardNameSchema, + layoutId: z.number().int().positive('Layout ID must be positive'), + name: z.string().min(1).max(200), + description: z.string().max(2000).optional().default(''), + isDraft: z.boolean(), + frames: z.string().min(1).max(10000), + framesCount: z.number().int().min(1).optional(), + framesPace: z.number().int().min(0).optional(), + angle: z.number().int().min(0).max(90), +}); + +export const SaveMoonBoardClimbInputSchema = z.object({ + boardType: z.literal('moonboard'), + layoutId: z.number().int().positive('Layout ID must be positive'), + name: z.string().min(1).max(200), + description: z.string().max(2000).optional().default(''), + holds: z.object({ + start: z.array(z.string()).default([]), + hand: z.array(z.string()).default([]), + finish: z.array(z.string()).default([]), + }), + angle: z.number().int().min(0).max(90), + isDraft: z.boolean().optional(), + userGrade: z.string().max(20).optional(), + isBenchmark: z.boolean().optional(), + setter: z.string().max(100).optional(), +}); + // ============================================ // Favorites Schemas // ============================================ diff --git a/packages/db/drizzle/0050_military_wonder_man.sql b/packages/db/drizzle/0050_military_wonder_man.sql new file mode 100644 index 000000000..f4f66c56e --- /dev/null +++ b/packages/db/drizzle/0050_military_wonder_man.sql @@ -0,0 +1,19 @@ +-- Safe migration for new climb subscriptions + +CREATE TABLE IF NOT EXISTS "new_climb_subscriptions" ( + "id" bigserial PRIMARY KEY, + "user_id" text NOT NULL, + "board_type" text NOT NULL, + "layout_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "new_climb_subscriptions_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS "new_climb_subscriptions_unique_user_board_layout" + ON "new_climb_subscriptions" ("user_id", "board_type", "layout_id"); + +CREATE INDEX IF NOT EXISTS "new_climb_subscriptions_user_idx" + ON "new_climb_subscriptions" ("user_id"); + +CREATE INDEX IF NOT EXISTS "new_climb_subscriptions_board_layout_idx" + ON "new_climb_subscriptions" ("board_type", "layout_id"); diff --git a/packages/db/drizzle/meta/0050_snapshot.json b/packages/db/drizzle/meta/0050_snapshot.json new file mode 100644 index 000000000..0806b6186 --- /dev/null +++ b/packages/db/drizzle/meta/0050_snapshot.json @@ -0,0 +1,4076 @@ +{ + "id": "ed529674-5dfb-4b49-a6c7-e9e4a8997ab5", + "prevId": "da1a564f-6689-4f12-b2a2-b02e7c1e09e6", + "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 + }, + "holds_hash": { + "name": "holds_hash", + "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": {} + }, + "board_climbs_holds_hash_idx": { + "name": "board_climbs_holds_hash_idx", + "columns": [ + { + "expression": "board_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "layout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "holds_hash", + "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.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 + } + }, + "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" + } + }, + "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 + }, + "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": {} + } + }, + "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" + } + }, + "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()" + } + }, + "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_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 + } + }, + "enums": { + "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" + ] + } + }, + "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 4f10aeab4..a87fdd490 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1769568000000, "tag": "0051_community_roles_nulls_not_distinct", "breakpoints": true + }, + { + "idx": 50, + "version": "7", + "when": 1770782294647, + "tag": "0050_military_wonder_man", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/app/index.ts b/packages/db/src/schema/app/index.ts index 10e6dad85..c69ccfb44 100644 --- a/packages/db/src/schema/app/index.ts +++ b/packages/db/src/schema/app/index.ts @@ -10,3 +10,4 @@ export * from './social'; export * from './notifications'; export * from './feed'; export * from './proposals'; +export * from './new-climb-subscriptions'; diff --git a/packages/db/src/schema/app/new-climb-subscriptions.ts b/packages/db/src/schema/app/new-climb-subscriptions.ts new file mode 100644 index 000000000..b8b96e724 --- /dev/null +++ b/packages/db/src/schema/app/new-climb-subscriptions.ts @@ -0,0 +1,21 @@ +import { pgTable, bigserial, text, integer, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core'; +import { users } from '../auth/users'; + +export const newClimbSubscriptions = pgTable('new_climb_subscriptions', { + id: bigserial('id', { mode: 'number' }).primaryKey(), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + boardType: text('board_type').notNull(), + layoutId: integer('layout_id').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => ({ + uniqueUserBoardLayout: uniqueIndex('new_climb_subscriptions_unique_user_board_layout').on( + table.userId, + table.boardType, + table.layoutId, + ), + userIdx: index('new_climb_subscriptions_user_idx').on(table.userId), + boardLayoutIdx: index('new_climb_subscriptions_board_layout_idx').on(table.boardType, table.layoutId), +})); + +export type NewClimbSubscription = typeof newClimbSubscriptions.$inferSelect; +export type NewNewClimbSubscription = typeof newClimbSubscriptions.$inferInsert; diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index faa4ae659..d53f91a55 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -1928,6 +1928,82 @@ export const typeDefs = /* GraphQL */ ` offset: Int } + # ============================================ + # New Climb Feed & Subscriptions + # ============================================ + + type NewClimbSubscription { + id: ID! + boardType: String! + layoutId: Int! + createdAt: String! + } + + input NewClimbSubscriptionInput { + boardType: String! + layoutId: Int! + } + + type NewClimbFeedItem { + uuid: ID! + name: String + boardType: String! + layoutId: Int! + setterDisplayName: String + setterAvatarUrl: String + angle: Int + frames: String + difficultyName: String + createdAt: String! + } + + type NewClimbFeedResult { + items: [NewClimbFeedItem!]! + totalCount: Int! + hasMore: Boolean! + } + + input NewClimbFeedInput { + boardType: String! + layoutId: Int! + limit: Int + offset: Int + } + + type NewClimbCreatedEvent { + climb: NewClimbFeedItem! + } + + input SaveClimbInput { + boardType: String! + layoutId: Int! + name: String! + description: String + isDraft: Boolean! + frames: String! + framesCount: Int + framesPace: Int + angle: Int! + } + + input SaveMoonBoardClimbInput { + boardType: String! + layoutId: Int! + name: String! + description: String + holds: JSON! + angle: Int! + isDraft: Boolean + userGrade: String + isBenchmark: Boolean + setter: String + } + + type SaveClimbResult { + uuid: ID! + synced: Boolean! + } + """ Root query type for all read operations. """ @@ -2182,6 +2258,17 @@ export const typeDefs = /* GraphQL */ ` """ trendingFeed(input: ActivityFeedInput): ActivityFeedResult! + """ + Get a feed of newly created climbs for a board type and layout. + """ + newClimbFeed(input: NewClimbFeedInput!): NewClimbFeedResult! + + """ + Get the current user's new climb subscriptions. + Requires authentication. + """ + myNewClimbSubscriptions: [NewClimbSubscription!]! + # ============================================ # Board Entity Queries # ============================================ @@ -2408,6 +2495,20 @@ export const typeDefs = /* GraphQL */ ` """ saveTick(input: SaveTickInput!): Tick! + # ============================================ + # Climb Mutations (require auth) + # ============================================ + + """ + Save a new climb for an Aurora-style board. + """ + saveClimb(input: SaveClimbInput!): SaveClimbResult! + + """ + Save a new MoonBoard climb. + """ + saveMoonBoardClimb(input: SaveMoonBoardClimbInput!): SaveClimbResult! + # ============================================ # Playlist Mutations (require auth) # ============================================ @@ -2464,6 +2565,16 @@ export const typeDefs = /* GraphQL */ ` """ unfollowUser(input: FollowInput!): Boolean! + """ + Subscribe to new climbs for a board type and layout. + """ + subscribeNewClimbs(input: NewClimbSubscriptionInput!): Boolean! + + """ + Unsubscribe from new climbs for a board type and layout. + """ + unsubscribeNewClimbs(input: NewClimbSubscriptionInput!): Boolean! + # ============================================ # Board Entity Mutations (require auth) # ============================================ @@ -2627,6 +2738,11 @@ export const typeDefs = /* GraphQL */ ` """ commentUpdates(entityType: SocialEntityType!, entityId: String!): CommentEvent! + """ + Subscribe to new climbs for a board type and layout. + """ + newClimbCreated(boardType: String!, layoutId: Int!): NewClimbCreatedEvent! + # ESP32 subscribes to receive LED commands - uses API key auth via connectionParams controllerEvents(sessionId: ID!): ControllerEvent! } diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 45e8d4905..2874cd9d4 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -548,6 +548,82 @@ export type NotificationEvent = { notification: Notification; }; +// ============================================ +// New Climb Feed & Subscriptions +// ============================================ + +export type NewClimbSubscription = { + id: string; + boardType: string; + layoutId: number; + createdAt: string; +}; + +export type NewClimbSubscriptionInput = { + boardType: string; + layoutId: number; +}; + +export type NewClimbFeedItem = { + uuid: string; + name?: string | null; + boardType: string; + layoutId: number; + setterDisplayName?: string | null; + setterAvatarUrl?: string | null; + angle?: number | null; + frames?: string | null; + difficultyName?: string | null; + createdAt: string; +}; + +export type NewClimbFeedResult = { + items: NewClimbFeedItem[]; + totalCount: number; + hasMore: boolean; +}; + +export type NewClimbFeedInput = { + boardType: string; + layoutId: number; + limit?: number; + offset?: number; +}; + +export type NewClimbCreatedEvent = { + climb: NewClimbFeedItem; +}; + +export type SaveClimbInput = { + boardType: string; + layoutId: number; + name: string; + description?: string | null; + isDraft: boolean; + frames: string; + framesCount?: number | null; + framesPace?: number | null; + angle: number; +}; + +export type SaveMoonBoardClimbInput = { + boardType: string; + layoutId: number; + name: string; + description?: string | null; + holds: unknown; + angle: number; + isDraft?: boolean | null; + userGrade?: string | null; + isBenchmark?: boolean | null; + setter?: string | null; +}; + +export type SaveClimbResult = { + uuid: string; + synced: boolean; +}; + export type CommentAdded = { __typename: 'CommentAdded'; comment: Comment; diff --git a/packages/web/app/components/activity-feed/ascent-thumbnail.tsx b/packages/web/app/components/activity-feed/ascent-thumbnail.tsx index 42a71ca85..91f0053f6 100644 --- a/packages/web/app/components/activity-feed/ascent-thumbnail.tsx +++ b/packages/web/app/components/activity-feed/ascent-thumbnail.tsx @@ -6,7 +6,8 @@ import { BoardDetails, BoardName } from '@/app/lib/types'; import BoardRenderer from '@/app/components/board-renderer/board-renderer'; import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; import { getBoardDetailsForBoard } from '@/app/lib/board-utils'; -import { getDefaultBoardConfig, getDefaultClimbViewPath } from '@/app/lib/default-board-configs'; +import { getDefaultBoardConfig } from '@/app/lib/default-board-configs'; +import { constructClimbViewUrlWithSlugs, constructClimbViewUrl } from '@/app/lib/url-utils'; import styles from './ascents-feed.module.css'; interface AscentThumbnailProps { @@ -56,11 +57,45 @@ const AscentThumbnail: React.FC = ({ return framesData[0]; }, [frames, boardType]); - // Get climb view path + // Get climb view path (prefer friendly slugs) const climbViewPath = useMemo(() => { if (!layoutId) return null; - return getDefaultClimbViewPath(boardType as BoardName, layoutId, angle, climbUuid); - }, [boardType, layoutId, angle, climbUuid]); + + const config = getDefaultBoardConfig(boardType as BoardName, layoutId); + if (config) { + const details = getBoardDetailsForBoard({ + board_name: boardType as BoardName, + layout_id: layoutId, + size_id: config.sizeId, + set_ids: config.setIds, + }); + if (details) { + return constructClimbViewUrlWithSlugs( + details.board_name, + details.layout_name, + details.size_name, + details.size_description, + details.set_names, + angle, + climbUuid, + climbName, + ); + } + } + + // Fallback to numeric path + return constructClimbViewUrl( + { + board_name: boardType as BoardName, + layout_id: layoutId, + size_id: config?.sizeId ?? 1, + set_ids: (config?.setIds ?? []).join(','), + angle, + }, + climbUuid, + climbName, + ); + }, [boardType, layoutId, angle, climbUuid, climbName]); // If we can't render the thumbnail, don't show anything if (!boardDetails || !litUpHoldsMap || !climbViewPath) { diff --git a/packages/web/app/components/back-button.tsx b/packages/web/app/components/back-button.tsx index f72e7802b..575358d0f 100644 --- a/packages/web/app/components/back-button.tsx +++ b/packages/web/app/components/back-button.tsx @@ -18,10 +18,24 @@ const BackButton = ({ fallbackUrl, className }: BackButtonProps) => { if (typeof window !== 'undefined') { const hasHistory = window.history.length > 1; const referrer = document.referrer; - const isSameOrigin = - referrer !== '' && (referrer.startsWith(window.location.origin) || referrer.includes('boardsesh.com')); - - setCanGoBack(hasHistory && isSameOrigin); + let isBoardseshReferrer = false; + + if (referrer) { + try { + const refUrl = new URL(referrer); + const host = refUrl.hostname; + const currentHost = window.location.hostname; + isBoardseshReferrer = + host === currentHost || + host.endsWith('boardsesh.com') || + host.endsWith('boardsesh.io') || + host === 'localhost'; + } catch { + isBoardseshReferrer = false; + } + } + + setCanGoBack(hasHistory && isBoardseshReferrer); } }, []); diff --git a/packages/web/app/components/board-bluetooth-control/plan b/packages/web/app/components/board-bluetooth-control/plan new file mode 100644 index 000000000..fd669c840 --- /dev/null +++ b/packages/web/app/components/board-bluetooth-control/plan @@ -0,0 +1,355 @@ +│ Plan to implement │ +│ │ +│ Milestone 7: New Climb Feed + Subscriptions │ +│ │ +│ Context │ +│ │ +│ Users need a way to discover new climbs being created on their board type/layout, get notified when followed setters create climbs, and subscribe to new climb notifications for specific board+layout combinations. This is the │ +│ last major social feature before unified search (Milestone 8). │ +│ │ +│ The key architectural decision: Move climb creation from the Next.js API route into a GraphQL mutation on the backend. This naturally integrates with the existing event pipeline (EventBroker + NotificationWorker) without needing │ +│ a cross-process bridge. │ +│ │ +│ --- │ +│ Implementation Steps │ +│ │ +│ Step 1: DB Schema — new_climb_subscriptions table │ +│ │ +│ Create: packages/db/src/schema/app/new-climb-subscriptions.ts │ +│ │ +│ Table definition per the social features plan section 1.6: │ +│ - id bigserial PK │ +│ - user_id text FK -> users.id CASCADE │ +│ - board_type text │ +│ - layout_id integer │ +│ - created_at timestamp DEFAULT now() │ +│ - Unique index: (user_id, board_type, layout_id) │ +│ - Index: (user_id), (board_type, layout_id) │ +│ │ +│ Follow the exact pattern from packages/db/src/schema/app/follows.ts (Drizzle pgTable, uniqueIndex, index). │ +│ │ +│ Modify: packages/db/src/schema/app/index.ts — add export * from './new-climb-subscriptions' │ +│ │ +│ Run: cd packages/db && npx drizzle-kit generate then npm run db:migrate │ +│ │ +│ --- │ +│ Step 2: GraphQL Schema + Shared Types │ +│ │ +│ Modify: packages/shared-schema/src/schema.ts │ +│ │ +│ Add types: │ +│ type NewClimbSubscription { id: ID!, boardType: String!, layoutId: Int!, createdAt: String! } │ +│ input NewClimbSubscriptionInput { boardType: String!, layoutId: Int! } │ +│ type NewClimbFeedItem { uuid: ID!, name: String, boardType: String!, layoutId: Int!, setterDisplayName: String, setterAvatarUrl: String, angle: Int, frames: String, difficultyName: String, createdAt: String! } │ +│ type NewClimbFeedResult { items: [NewClimbFeedItem!]!, totalCount: Int!, hasMore: Boolean! } │ +│ input NewClimbFeedInput { boardType: String!, layoutId: Int!, limit: Int, offset: Int } │ +│ type NewClimbCreatedEvent { climb: NewClimbFeedItem! } │ +│ │ +│ input SaveClimbInput { boardType: String!, layoutId: Int!, name: String!, description: String, isDraft: Boolean!, frames: String!, framesCount: Int, framesPace: Int, angle: Int! } │ +│ input SaveMoonBoardClimbInput { boardType: String!, layoutId: Int!, name: String!, description: String, holds: JSON!, angle: Int!, isDraft: Boolean, userGrade: String, isBenchmark: Boolean, setter: String } │ +│ type SaveClimbResult { uuid: ID!, synced: Boolean! } │ +│ │ +│ Add to Query: newClimbFeed(input: NewClimbFeedInput!): NewClimbFeedResult!, myNewClimbSubscriptions: [NewClimbSubscription!]! │ +│ Add to Mutation: subscribeNewClimbs(input: NewClimbSubscriptionInput!): Boolean!, unsubscribeNewClimbs(input: NewClimbSubscriptionInput!): Boolean!, saveClimb(input: SaveClimbInput!): SaveClimbResult!, saveMoonBoardClimb(input: │ +│ SaveMoonBoardClimbInput!): SaveClimbResult! │ +│ Add to Subscription: newClimbCreated(boardType: String!, layoutId: Int!): NewClimbCreatedEvent! │ +│ │ +│ Modify: packages/shared-schema/src/types.ts — add corresponding TS types including NewClimbCreatedEvent │ +│ │ +│ --- │ +│ Step 3: PubSub — New Climb Channel │ +│ │ +│ Modify: packages/backend/src/pubsub/index.ts │ +│ │ +│ Add a 5th subscriber type following the existing comment channel pattern: │ +│ - type NewClimbSubscriber = (event: NewClimbCreatedEvent) => void; │ +│ - private newClimbSubscribers: Map> │ +│ - subscribeNewClimbs(channelKey, callback) — channelKey = ${boardType}:${layoutId} │ +│ - publishNewClimbEvent(channelKey, event) │ +│ - dispatchToLocalNewClimbSubscribers(channelKey, event) │ +│ │ +│ Wire up the Redis message handler in setupRedisMessageHandlers(). │ +│ │ +│ Modify: packages/backend/src/pubsub/redis-adapter.ts │ +│ │ +│ Add NEW_CLIMB_CHANNEL_PREFIX = 'boardsesh:new-climbs:' and the full set of methods following the comment channel pattern: │ +│ - publishNewClimbEvent, subscribeNewClimbChannel, unsubscribeNewClimbChannel, onNewClimbMessage │ +│ - Update RedisMessage union type and subscriber.on('message') handler │ +│ - Update RedisPubSubAdapter interface │ +│ │ +│ --- │ +│ Step 4: Backend — saveClimb GraphQL Mutation │ +│ │ +│ Create: packages/backend/src/graphql/resolvers/climbs/mutations.ts │ +│ │ +│ Move the climb creation logic from packages/web/app/lib/api-wrappers/aurora/saveClimb.ts into a GraphQL mutation: │ +│ │ +│ export const climbMutations = { │ +│ saveClimb: async (_, { input }, ctx) => { │ +│ requireAuthenticated(ctx); │ +│ applyRateLimit(ctx, 10); │ +│ // Validate input with Zod │ +│ // Generate UUID, insert into UNIFIED_TABLES.climbs (same logic as existing saveClimb.ts) │ +│ // After successful insert: publishSocialEvent({ type: 'climb.created', ... }) │ +│ return { uuid, synced: false }; │ +│ }, │ +│ saveMoonBoardClimb: async (_, { input }, ctx) => { │ +│ // Similar but with Moonboard-specific hold encoding and optional grade stats │ +│ }, │ +│ }; │ +│ │ +│ Reuse existing utilities: │ +│ - UNIFIED_TABLES from packages/backend/src/db/queries/util/table-select.ts │ +│ - generateUuid pattern from packages/web/app/lib/api-wrappers/aurora/util.ts (reimplement in backend or use crypto.randomUUID()) │ +│ - publishSocialEvent from packages/backend/src/events/index.ts │ +│ │ +│ Modify: packages/backend/src/graphql/resolvers/index.ts — import and spread climbMutations │ +│ │ +│ --- │ +│ Step 5: Backend — Subscription Management Resolvers │ +│ │ +│ Create: packages/backend/src/graphql/resolvers/social/new-climb-subscriptions.ts │ +│ │ +│ Queries: │ +│ - newClimbFeed(input) — Query boardClimbs filtered by boardType + layoutId, JOIN with boardClimbStats for difficulty and users/userProfiles for setter info. ORDER BY createdAt DESC. Offset/limit pagination. No auth required. │ +│ - myNewClimbSubscriptions — Requires auth. SELECT from newClimbSubscriptions WHERE userId = ctx.userId. │ +│ │ +│ Mutations: │ +│ - subscribeNewClimbs(input) — Requires auth. INSERT with onConflictDoNothing(). Rate limited. │ +│ - unsubscribeNewClimbs(input) — Requires auth. DELETE matching row. │ +│ │ +│ Create: packages/backend/src/graphql/resolvers/social/new-climb-feed-subscription.ts │ +│ │ +│ WebSocket subscription following packages/backend/src/graphql/resolvers/social/comment-subscriptions.ts: │ +│ - Validate boardType against SUPPORTED_BOARDS and layoutId is positive integer │ +│ - Channel key: ${boardType}:${layoutId} │ +│ - Use createAsyncIterator from packages/backend/src/graphql/resolvers/shared/async-iterators.ts │ +│ - Yield { newClimbCreated: event } │ +│ │ +│ Modify: packages/backend/src/graphql/resolvers/index.ts — add to Query, Mutation, and Subscription │ +│ │ +│ --- │ +│ Step 6: Backend — NotificationWorker + Recipient Resolution │ +│ │ +│ Modify: packages/backend/src/events/notification-worker.ts │ +│ │ +│ Add case 'climb.created': await this.handleClimbCreated(event); │ +│ │ +│ The handleClimbCreated method: │ +│ 1. Extract boardType, layoutId, climbName from event metadata │ +│ 2. Get follower recipients (people following the setter) → new_climb notifications │ +│ 3. Get subscription recipients (people subscribed to board+layout) → new_climb_global notifications │ +│ 4. Deduplicate: followers take priority (if someone is both follower + subscriber → one new_climb notification) │ +│ 5. Create notifications for all recipients (excluding actor) │ +│ 6. Fan out feed items to followers │ +│ 7. Publish real-time event via pubsub.publishNewClimbEvent(channelKey, event) │ +│ │ +│ Modify: packages/backend/src/events/recipient-resolution.ts │ +│ │ +│ Add two new functions: │ +│ - resolveClimbCreatedFollowerRecipients(setterId) — query userFollows WHERE followingId = setterId │ +│ - resolveClimbCreatedSubscriptionRecipients(boardType, layoutId, excludeUserId) — query newClimbSubscriptions WHERE boardType AND layoutId │ +│ │ +│ Both return RecipientInfo[] with appropriate notificationType. │ +│ │ +│ Modify: packages/backend/src/events/feed-fanout.ts │ +│ │ +│ Add fanoutNewClimbFeedItems(event) — same pattern as fanoutFeedItems but with type: 'new_climb' and entityType: 'climb'. │ +│ │ +│ Modify: packages/backend/src/events/index.ts │ +│ │ +│ Add climb.created case to createInlineNotification fallback (for non-Redis mode). Since it's multi-recipient, do basic fan-out + WS publishing inline. │ +│ │ +│ Modify: packages/backend/src/events/notification-worker.ts enrichNotification method │ +│ │ +│ Add climb metadata lookup for new_climb and new_climb_global notification types: fetch climbName, climbUuid, boardType from boardClimbs table. │ +│ │ +│ --- │ +│ Step 7: Frontend — Update saveClimb Callers │ +│ │ +│ Modify: packages/web/app/components/board-provider/board-provider-context.tsx │ +│ │ +│ Change saveClimb to call the GraphQL backend mutation instead of fetch('/api/v1/.../proxy/saveClimb'). Use the existing graphql-ws client pattern (the execute helper from │ +│ packages/web/app/components/graphql-queue/graphql-client.ts). │ +│ │ +│ Modify: packages/web/app/components/create-climb/create-climb-form.tsx │ +│ │ +│ Update the MoonBoard save path to also go through GraphQL (replace direct fetch call with the saveMoonBoardClimb mutation via the GraphQL client). │ +│ │ +│ Keep for now: packages/web/app/api/v1/[board_name]/proxy/saveClimb/route.ts — keep the API route as a fallback / deprecation path. Don't delete it yet, but it won't trigger notifications. │ +│ │ +│ --- │ +│ Step 8: Frontend — GraphQL Operations │ +│ │ +│ Create: packages/web/app/lib/graphql/operations/new-climb-feed.ts │ +│ │ +│ Define all GraphQL operations: │ +│ - GET_NEW_CLIMB_FEED query │ +│ - GET_MY_NEW_CLIMB_SUBSCRIPTIONS query │ +│ - SUBSCRIBE_NEW_CLIMBS mutation │ +│ - UNSUBSCRIBE_NEW_CLIMBS mutation │ +│ - NEW_CLIMB_CREATED_SUBSCRIPTION subscription │ +│ - SAVE_CLIMB_MUTATION mutation │ +│ - SAVE_MOONBOARD_CLIMB_MUTATION mutation │ +│ │ +│ --- │ +│ Step 9: Frontend — New Climb Feed Components │ +│ │ +│ Create: packages/web/app/components/new-climb-feed/subscribe-button.tsx │ +│ │ +│ MUI toggle button with notification bell icon. Uses execute() from graphql-client to call subscribe/unsubscribe mutations. Props: boardType, layoutId, isSubscribed, onSubscriptionChange. │ +│ │ +│ Create: packages/web/app/components/new-climb-feed/new-climb-feed-item.tsx │ +│ │ +│ Card component for a new climb. Reuse AscentThumbnail from packages/web/app/components/activity-feed/ascent-thumbnail.tsx. Show: setter avatar+name, climb name, difficulty chip, angle chip, board type, relative time, board │ +│ thumbnail. │ +│ │ +│ Create: packages/web/app/components/new-climb-feed/new-climb-feed.tsx │ +│ │ +│ Main feed component: │ +│ 1. Fetch initial data via execute() with GET_NEW_CLIMB_FEED │ +│ 2. Subscribe to NEW_CLIMB_CREATED_SUBSCRIPTION via subscribe() from graphql-client for real-time updates │ +│ 3. Prepend new climbs to the list as they arrive │ +│ 4. Offset-based pagination with "Load more" button │ +│ 5. Props: boardType, layoutId, isAuthenticated, isSubscribed │ +│ │ +│ Create: packages/web/app/components/new-climb-feed/index.ts — barrel export │ +│ │ +│ --- │ +│ Step 10: Frontend — Home Page Integration │ +│ │ +│ Modify: packages/web/app/home-page-content.tsx │ +│ │ +│ Add tab system to toggle between "Activity" and "New Climbs": │ +│ - Use MUI Tabs / Tab components │ +│ - "Activity" tab shows existing ActivityFeed │ +│ - "New Climbs" tab shows NewClimbFeed (needs a board+layout selector or uses the user's default board) │ +│ - When selectedBoardUuid is set via BoardSelectorPills, use it to determine boardType+layoutId for the new climb feed │ +│ - Persist selected tab preference to IndexedDB via setPreference/getPreference │ +│ │ +│ The New Climbs tab needs a board+layout to filter by. When a user selects a board via BoardSelectorPills, look up the board's boardType and layoutId from the board entity. If no board is selected, show a prompt to select or │ +│ create a board first. │ +│ │ +│ --- │ +│ Step 11: Validation Schemas │ +│ │ +│ Modify: packages/backend/src/validation/schemas.ts │ +│ │ +│ Add Zod schemas: │ +│ - NewClimbSubscriptionInputSchema │ +│ - NewClimbFeedInputSchema │ +│ - SaveClimbInputSchema │ +│ - SaveMoonBoardClimbInputSchema │ +│ │ +│ --- │ +│ Step 12: Update Milestone Doc │ +│ │ +│ Modify: docs/social-features-plan.md │ +│ │ +│ Mark Milestone 7 as [COMPLETED]. │ +│ │ +│ --- │ +│ Files Summary │ +│ │ +│ New Files │ +│ ┌──────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────┐ │ +│ │ File │ Purpose │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/db/src/schema/app/new-climb-subscriptions.ts │ DB table schema │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/graphql/resolvers/climbs/mutations.ts │ saveClimb GraphQL mutation │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/graphql/resolvers/social/new-climb-subscriptions.ts │ Feed query + subscription management mutations │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/graphql/resolvers/social/new-climb-feed-subscription.ts │ WebSocket subscription resolver │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/web/app/lib/graphql/operations/new-climb-feed.ts │ Frontend GraphQL operations │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/web/app/components/new-climb-feed/subscribe-button.tsx │ Subscribe toggle button │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/web/app/components/new-climb-feed/new-climb-feed-item.tsx │ Feed item card │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/web/app/components/new-climb-feed/new-climb-feed.tsx │ Main feed with live updates │ │ +│ ├──────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────┤ │ +│ │ packages/web/app/components/new-climb-feed/index.ts │ Barrel export │ │ +│ └──────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────┘ │ +│ Modified Files │ +│ ┌───────────────────────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────┐ │ +│ │ File │ Changes │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/db/src/schema/app/index.ts │ Export new table │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/shared-schema/src/schema.ts │ Add GraphQL types/inputs/queries/mutations/subscriptions │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/shared-schema/src/types.ts │ Add TS types │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/pubsub/index.ts │ Add newClimb channel (subscribe/publish/dispatch) │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/pubsub/redis-adapter.ts │ Add newClimb Redis channel │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/graphql/resolvers/index.ts │ Register new resolvers │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/events/notification-worker.ts │ Handle climb.created event + enrich notifications │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/events/recipient-resolution.ts │ Add follower + subscription recipient resolvers │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/events/feed-fanout.ts │ Add fanoutNewClimbFeedItems │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/events/index.ts │ Add climb.created inline fallback │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/backend/src/validation/schemas.ts │ Add Zod schemas │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/web/app/components/board-provider/board-provider-context.tsx │ Use GraphQL mutation for saveClimb │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/web/app/components/create-climb/create-climb-form.tsx │ Use GraphQL for Moonboard save │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ packages/web/app/home-page-content.tsx │ Add "New Climbs" tab │ │ +│ ├───────────────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ │ +│ │ docs/social-features-plan.md │ Mark milestone 7 as completed │ │ +│ └───────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────┘ │ +│ Existing Code to Reuse │ +│ ┌───────────────────────────────────────────┬───────────────────────────────────────────────────────────────────┐ │ +│ │ Pattern/Utility │ File │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ UNIFIED_TABLES.climbs │ packages/backend/src/db/queries/util/table-select.ts │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ publishSocialEvent() │ packages/backend/src/events/index.ts │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ fanoutFeedItems() pattern │ packages/backend/src/events/feed-fanout.ts │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ RecipientInfo type │ packages/backend/src/events/recipient-resolution.ts │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ createAsyncIterator() │ packages/backend/src/graphql/resolvers/shared/async-iterators.ts │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ requireAuthenticated() / applyRateLimit() │ packages/backend/src/graphql/resolvers/shared/helpers.ts │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ AscentThumbnail component │ packages/web/app/components/activity-feed/ascent-thumbnail.tsx │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ createGraphQLClient / execute / subscribe │ packages/web/app/components/graphql-queue/graphql-client.ts │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ FeedItemNewClimb design │ packages/web/app/components/activity-feed/feed-item-new-climb.tsx │ │ +│ ├───────────────────────────────────────────┼───────────────────────────────────────────────────────────────────┤ │ +│ │ Comment channel PubSub pattern │ packages/backend/src/pubsub/index.ts lines 421-484 │ │ +│ └───────────────────────────────────────────┴───────────────────────────────────────────────────────────────────┘ │ +│ --- │ +│ Verification │ +│ │ +│ Typecheck │ +│ │ +│ npm run typecheck │ +│ │ +│ Manual Testing │ +│ │ +│ 1. New climb feed: Navigate to home page, select "New Climbs" tab with a board selected. Verify recent climbs appear for that board+layout. │ +│ 2. Subscribe: Click "Subscribe" button for a board+layout. Verify subscription is persisted (reload page, button shows "Subscribed"). │ +│ 3. Create climb + feed update: With the feed open in one browser tab, create a new climb in another tab. Verify the climb appears in real-time in the feed tab. │ +│ 4. Notifications: Follow a user, have them create a climb. Verify you receive a new_climb notification. Subscribe to a board+layout, have someone else create a climb on that config. Verify you receive a new_climb_global │ +│ notification. │ +│ 5. Dedup: Be both a follower of a setter AND subscribed to their board+layout. Create a climb. Verify you get exactly ONE notification (type new_climb, not two). │ +│ 6. saveClimb migration: Create a climb via the existing UI (Aurora boards and Moonboard). Verify it still saves correctly and now also publishes events. │ +│ │ +│ Edge Cases │ +│ │ +│ - Unsubscribe and verify no more notifications │ +│ - Non-authenticated users see the feed but not the subscribe button │ +│ - Creating a draft climb should still publish events (setters may want to preview) \ No newline at end of file diff --git a/packages/web/app/components/board-entity/board-selector-pills.tsx b/packages/web/app/components/board-entity/board-selector-pills.tsx index 9698a08bf..28093d4dd 100644 --- a/packages/web/app/components/board-entity/board-selector-pills.tsx +++ b/packages/web/app/components/board-entity/board-selector-pills.tsx @@ -73,6 +73,7 @@ export default function BoardSelectorPills({ } else { onBoardSelect?.(board); } + onBoardSelect?.(board); }; const handleAllClick = () => { diff --git a/packages/web/app/components/board-provider/board-provider-context.tsx b/packages/web/app/components/board-provider/board-provider-context.tsx index 41593ad4e..2b2f5cc5c 100644 --- a/packages/web/app/components/board-provider/board-provider-context.tsx +++ b/packages/web/app/components/board-provider/board-provider-context.tsx @@ -14,6 +14,12 @@ import { type SaveTickMutationVariables, type SaveTickMutationResponse, } from '@/app/lib/graphql/operations'; +import { + SAVE_CLIMB_MUTATION, + type SaveClimbMutationVariables, + type SaveClimbMutationResponse, +} from '@/app/lib/graphql/operations/new-climb-feed'; +import { createGraphQLClient, execute, type Client } from '../graphql-queue/graphql-client'; export interface SaveClimbResponse { uuid: string; @@ -90,6 +96,7 @@ export function BoardProvider({ boardName, children }: { boardName: BoardName; c // Use ref to track climb UUIDs to avoid re-render loops const currentClimbUuidsRef = useRef([]); const lastSessionStatusRef = useRef(sessionStatus); + const graphqlClientRef = useRef(null); // Initialize when session status changes useEffect(() => { @@ -157,6 +164,14 @@ export function BoardProvider({ boardName, children }: { boardName: BoardName; c } }; + // Reset GraphQL client when auth token changes + useEffect(() => { + if (graphqlClientRef.current && 'dispose' in graphqlClientRef.current) { + graphqlClientRef.current.dispose(); + } + graphqlClientRef.current = null; + }, [wsAuthToken]); + // Fetch logbook from local ticks API (works without Aurora credentials) // Returns true if the fetch was successful, false if it was skipped or failed const getLogbook = useCallback(async (climbUuids: ClimbUuid[]): Promise => { @@ -279,31 +294,38 @@ export function BoardProvider({ boardName, children }: { boardName: BoardName; c // Save a climb (requires authentication) const saveClimb = async (options: Omit): Promise => { - if (sessionStatus !== 'authenticated' || !session?.user?.id) { + if (sessionStatus !== 'authenticated' || !session?.user?.id || !wsAuthToken) { throw new Error('Authentication required to create climbs'); } try { - const response = await fetch(`/api/v1/${boardName}/proxy/saveClimb`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - options: { - ...options, - user_id: session.user.id, - }, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to save climb'); + if (!graphqlClientRef.current) { + graphqlClientRef.current = createGraphQLClient({ + url: process.env.NEXT_PUBLIC_WS_URL!, + authToken: wsAuthToken, + }); } - const data = await response.json(); - return data; + const variables: SaveClimbMutationVariables = { + input: { + boardType: boardName, + layoutId: options.layout_id, + name: options.name, + description: options.description || '', + isDraft: options.is_draft, + frames: options.frames, + framesCount: options.frames_count, + framesPace: options.frames_pace, + angle: options.angle, + }, + }; + + const result = await execute( + graphqlClientRef.current, + { query: SAVE_CLIMB_MUTATION, variables }, + ); + + return result.saveClimb; } catch (err) { showMessage('Failed to save climb', 'error'); throw err; diff --git a/packages/web/app/components/create-climb/create-climb-form.tsx b/packages/web/app/components/create-climb/create-climb-form.tsx index 81c0d9565..b39010c61 100644 --- a/packages/web/app/components/create-climb/create-climb-form.tsx +++ b/packages/web/app/components/create-climb/create-climb-form.tsx @@ -35,10 +35,17 @@ import { useColorMode } from '@/app/hooks/use-color-mode'; import { themeTokens } from '@/app/theme/theme-config'; import { parseScreenshot } from '@boardsesh/moonboard-ocr/browser'; import { convertOcrHoldsToMap } from '@/app/lib/moonboard-climbs-db'; +import { createGraphQLClient, execute, type Client } from '../graphql-queue/graphql-client'; import AuthModal from '../auth/auth-modal'; import { useSnackbar } from '../providers/snackbar-provider'; import CreateClimbHeatmapOverlay from './create-climb-heatmap-overlay'; import styles from './create-climb-form.module.css'; +import { + SAVE_MOONBOARD_CLIMB_MUTATION, + type SaveMoonBoardClimbMutationVariables, + type SaveMoonBoardClimbMutationResponse, +} from '@/app/lib/graphql/operations/new-climb-feed'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; interface CreateClimbFormValues { @@ -81,6 +88,7 @@ export default function CreateClimbForm({ // Aurora-specific hooks const { isAuthenticated, saveClimb } = useBoardProvider(); const { showMessage } = useSnackbar(); + const { token: wsAuthToken } = useWsAuthToken(); // Determine which auth check to use based on board type const isLoggedIn = boardType === 'aurora' ? isAuthenticated : !!session?.user?.id; @@ -120,6 +128,7 @@ export default function CreateClimbForm({ // Refs const fileInputRef = useRef(null); + const graphqlClientRef = useRef(null); // Form state const [isSaving, setIsSaving] = useState(false); @@ -279,29 +288,37 @@ export default function CreateClimbForm({ .map(([id]) => holdIdToCoordinate(Number(id))), }; - const response = await fetch('/api/v1/moonboard/proxy/saveClimb', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - options: { - layout_id: layoutId, - user_id: userId, - name: climbName, - description: description || '', - holds, - angle: selectedAngle, - is_draft: isDraft, - user_grade: userGrade, - is_benchmark: isBenchmark, - }, - }), - }); + if (!wsAuthToken) { + throw new Error('Authentication required to save climb'); + } - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to save climb'); + if (!graphqlClientRef.current) { + graphqlClientRef.current = createGraphQLClient({ + url: process.env.NEXT_PUBLIC_WS_URL!, + authToken: wsAuthToken, + }); } + const variables: SaveMoonBoardClimbMutationVariables = { + input: { + boardType: 'moonboard', + layoutId, + name: climbName, + description: description || '', + holds, + angle: selectedAngle, + isDraft: isDraft, + userGrade, + isBenchmark, + setter: undefined, + }, + }; + + await execute( + graphqlClientRef.current, + { query: SAVE_MOONBOARD_CLIMB_MUTATION, variables }, + ); + showMessage('Climb saved to database!', 'success'); const listUrl = pathname.replace(/\/create$/, '/list'); @@ -312,7 +329,7 @@ export default function CreateClimbForm({ } finally { setIsSaving(false); } - }, [layoutId, session, litUpHoldsMap, climbName, description, userGrade, isBenchmark, isDraft, selectedAngle, pathname, router]); + }, [layoutId, session, litUpHoldsMap, climbName, description, userGrade, isBenchmark, isDraft, selectedAngle, pathname, router, wsAuthToken]); const handlePublish = useCallback(async () => { if (!isValid || !climbName.trim()) { diff --git a/packages/web/app/components/new-climb-feed/index.ts b/packages/web/app/components/new-climb-feed/index.ts new file mode 100644 index 000000000..8f322a4a3 --- /dev/null +++ b/packages/web/app/components/new-climb-feed/index.ts @@ -0,0 +1,3 @@ +export { default as NewClimbFeed } from './new-climb-feed'; +export { default as SubscribeButton } from './subscribe-button'; +export { default as NewClimbFeedItem } from './new-climb-feed-item'; diff --git a/packages/web/app/components/new-climb-feed/new-climb-feed-item.tsx b/packages/web/app/components/new-climb-feed/new-climb-feed-item.tsx new file mode 100644 index 000000000..1ba39f579 --- /dev/null +++ b/packages/web/app/components/new-climb-feed/new-climb-feed-item.tsx @@ -0,0 +1,113 @@ +'use client'; + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import PersonOutlined from '@mui/icons-material/PersonOutlined'; +import LocationOnOutlined from '@mui/icons-material/LocationOnOutlined'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import type { NewClimbFeedItem } from '@boardsesh/shared-schema'; +import AscentThumbnail from '@/app/components/activity-feed/ascent-thumbnail'; +import { getDefaultBoardConfig, getDefaultClimbViewPath } from '@/app/lib/default-board-configs'; +import { getBoardDetailsForBoard } from '@/app/lib/board-utils'; +import { constructClimbViewUrlWithSlugs } from '@/app/lib/url-utils'; +import type { BoardName } from '@/app/lib/types'; +import Link from 'next/link'; + +dayjs.extend(relativeTime); + +interface NewClimbFeedItemProps { + item: NewClimbFeedItem; +} + +export default function NewClimbFeedItem({ item }: NewClimbFeedItemProps) { + const timeAgo = dayjs(item.createdAt).fromNow(); + const climbViewPath = (() => { + const boardName = item.boardType as BoardName; + const angle = item.angle ?? 0; + + // Try to build friendly slug path using board details + const defaultConfig = getDefaultBoardConfig(boardName, item.layoutId); + if (defaultConfig) { + const details = getBoardDetailsForBoard({ + board_name: boardName, + layout_id: item.layoutId, + size_id: defaultConfig.sizeId, + set_ids: defaultConfig.setIds, + }); + + if (details) { + return constructClimbViewUrlWithSlugs( + boardName, + details.layout_name, + details.size_name, + details.size_description, + details.set_names, + angle, + item.uuid, + item.name || undefined, + ); + } + } + + // Fallback to default numeric path + return getDefaultClimbViewPath(boardName, item.layoutId, angle, item.uuid); + })(); + + return ( + + + + + {!item.setterAvatarUrl && } + + + + {item.setterDisplayName || 'Setter'} + + + {timeAgo} + + + {item.boardType && ( + + {item.boardType} + + )} + + + + + {item.frames && ( + + )} + + + + {item.name || 'Untitled climb'} + + + {item.difficultyName && } + {item.angle != null && ( + } label={`${item.angle}\u00B0`} size="small" /> + )} + + + + + + + ); +} diff --git a/packages/web/app/components/new-climb-feed/new-climb-feed.tsx b/packages/web/app/components/new-climb-feed/new-climb-feed.tsx new file mode 100644 index 000000000..1a7a04531 --- /dev/null +++ b/packages/web/app/components/new-climb-feed/new-climb-feed.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import Alert from '@mui/material/Alert'; +import type { Client } from '../graphql-queue/graphql-client'; +import { createGraphQLClient, execute, subscribe } from '../graphql-queue/graphql-client'; +import { + GET_NEW_CLIMB_FEED, + NEW_CLIMB_CREATED_SUBSCRIPTION, + type GetNewClimbFeedResponse, + type GetNewClimbFeedVariables, + type NewClimbCreatedSubscriptionPayload, +} from '@/app/lib/graphql/operations/new-climb-feed'; +import type { NewClimbFeedItem as NewClimbFeedItemType } from '@boardsesh/shared-schema'; +import NewClimbFeedItem from './new-climb-feed-item'; +import SubscribeButton from './subscribe-button'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; + +interface NewClimbFeedProps { + boardType: string; + layoutId: number; + isAuthenticated: boolean; + isSubscribed?: boolean; +} + +const PAGE_SIZE = 20; + +export default function NewClimbFeed({ boardType, layoutId, isAuthenticated, isSubscribed = false }: NewClimbFeedProps) { + const { token: wsAuthToken } = useWsAuthToken(); + const clientRef = useRef(null); + const subscriptionRef = useRef<() => void>(); + const [items, setItems] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [subscribed, setSubscribed] = useState(isSubscribed); + const [nextOffset, setNextOffset] = useState(0); + + const ensureClient = useCallback(() => { + if (!clientRef.current) { + clientRef.current = createGraphQLClient({ + url: process.env.NEXT_PUBLIC_WS_URL!, + authToken: wsAuthToken, + }); + } + return clientRef.current; + }, [wsAuthToken]); + + const fetchPage = useCallback( + async (offset = 0, append = false) => { + setLoading(true); + setError(null); + try { + const client = ensureClient(); + const variables: GetNewClimbFeedVariables = { + input: { boardType, layoutId, limit: PAGE_SIZE, offset }, + }; + const res = await execute(client, { + query: GET_NEW_CLIMB_FEED, + variables, + }); + + setItems((prev) => (append ? [...prev, ...res.newClimbFeed.items] : res.newClimbFeed.items)); + setTotalCount(res.newClimbFeed.totalCount); + setHasMore(res.newClimbFeed.hasMore); + setNextOffset(offset + res.newClimbFeed.items.length); + } catch (err) { + console.error('Failed to fetch new climb feed', err); + setError('Failed to load climbs. Please try again.'); + } finally { + setLoading(false); + } + }, + [boardType, layoutId, ensureClient], + ); + + // Initial load and when board/layout changes + useEffect(() => { + setItems([]); + setTotalCount(0); + setHasMore(false); + setNextOffset(0); + fetchPage(0, false); + }, [boardType, layoutId, fetchPage]); + + // Re-run subscription when board/layout changes + useEffect(() => { + const client = ensureClient(); + if (subscriptionRef.current) { + subscriptionRef.current(); + } + subscriptionRef.current = subscribe( + client, + { query: NEW_CLIMB_CREATED_SUBSCRIPTION, variables: { boardType, layoutId } }, + { + next: (data) => { + const newItem = data.newClimbCreated.climb; + setItems((prev) => { + const exists = prev.some((i) => i.uuid === newItem.uuid); + if (exists) return prev; + setTotalCount((count) => count + 1); + return [newItem, ...prev].slice(0, PAGE_SIZE); + }); + }, + error: (err) => console.error('New climb subscription error', err), + }, + ); + + return () => { + subscriptionRef.current?.(); + subscriptionRef.current = undefined; + }; + }, [boardType, layoutId, ensureClient]); + + return ( + + + + New Climbs + + + + + {error && {error}} + + {items.map((item) => ( + + ))} + + {loading && ( + + + + )} + + {!loading && hasMore && ( + + + + )} + + {!loading && items.length === 0 && !error && ( + + No climbs yet for this layout. Be the first to set one! + + )} + + ); +} diff --git a/packages/web/app/components/new-climb-feed/subscribe-button.tsx b/packages/web/app/components/new-climb-feed/subscribe-button.tsx new file mode 100644 index 000000000..8175d42ac --- /dev/null +++ b/packages/web/app/components/new-climb-feed/subscribe-button.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState, useRef, useCallback } from 'react'; +import ToggleButton from '@mui/material/ToggleButton'; +import NotificationsNoneOutlined from '@mui/icons-material/NotificationsNoneOutlined'; +import NotificationsActiveOutlined from '@mui/icons-material/NotificationsActiveOutlined'; +import CircularProgress from '@mui/material/CircularProgress'; +import { useSnackbar } from '@/app/components/providers/snackbar-provider'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { createGraphQLClient, execute, type Client } from '@/app/components/graphql-queue/graphql-client'; +import { + SUBSCRIBE_NEW_CLIMBS, + UNSUBSCRIBE_NEW_CLIMBS, + type SubscribeNewClimbsVariables, + type SubscribeNewClimbsResponse, + type UnsubscribeNewClimbsVariables, + type UnsubscribeNewClimbsResponse, +} from '@/app/lib/graphql/operations/new-climb-feed'; + +interface SubscribeButtonProps { + boardType: string; + layoutId: number; + isSubscribed: boolean; + onSubscriptionChange?: (isSubscribed: boolean) => void; + disabled?: boolean; +} + +export default function SubscribeButton({ + boardType, + layoutId, + isSubscribed, + onSubscriptionChange, + disabled, +}: SubscribeButtonProps) { + const { token: wsAuthToken } = useWsAuthToken(); + const { showMessage } = useSnackbar(); + const clientRef = useRef(null); + const [loading, setLoading] = useState(false); + + const ensureClient = useCallback(() => { + if (!clientRef.current) { + clientRef.current = createGraphQLClient({ + url: process.env.NEXT_PUBLIC_WS_URL!, + authToken: wsAuthToken, + }); + } + return clientRef.current; + }, [wsAuthToken]); + + const handleToggle = async () => { + if (loading || disabled) return; + if (!wsAuthToken) { + showMessage('Please sign in to manage subscriptions', 'warning'); + return; + } + + setLoading(true); + try { + const client = ensureClient(); + + if (isSubscribed) { + const variables: UnsubscribeNewClimbsVariables = { + input: { boardType, layoutId }, + }; + await execute(client, { + query: UNSUBSCRIBE_NEW_CLIMBS, + variables, + }); + onSubscriptionChange?.(false); + showMessage('Unsubscribed from new climbs', 'success'); + } else { + const variables: SubscribeNewClimbsVariables = { + input: { boardType, layoutId }, + }; + await execute(client, { + query: SUBSCRIBE_NEW_CLIMBS, + variables, + }); + onSubscriptionChange?.(true); + showMessage('Subscribed to new climbs', 'success'); + } + } catch (err) { + console.error('Subscription toggle failed', err); + showMessage('Could not update subscription', 'error'); + } finally { + setLoading(false); + } + }; + + return ( + + {loading ? ( + + ) : isSubscribed ? ( + + ) : ( + + )} + {isSubscribed ? 'Subscribed' : 'Subscribe'} + + ); +} diff --git a/packages/web/app/home-page-content.tsx b/packages/web/app/home-page-content.tsx index d72358cb7..4d6354fee 100644 --- a/packages/web/app/home-page-content.tsx +++ b/packages/web/app/home-page-content.tsx @@ -3,6 +3,8 @@ import React, { useState, useCallback, useEffect } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; import SearchOutlined from '@mui/icons-material/SearchOutlined'; import ActivityFeed from '@/app/components/activity-feed/activity-feed'; import FeedSortSelector from '@/app/components/activity-feed/feed-sort-selector'; @@ -19,6 +21,14 @@ import BoardSelectorPills from '@/app/components/board-entity/board-selector-pil import type { SortMode } from '@boardsesh/shared-schema'; import bottomBarStyles from '@/app/components/bottom-tab-bar/bottom-bar-wrapper.module.css'; import { getPreference, setPreference } from '@/app/lib/user-preferences-db'; +import { NewClimbFeed } from '@/app/components/new-climb-feed'; +import type { UserBoard, NewClimbSubscription } from '@boardsesh/shared-schema'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { createGraphQLHttpClient } from '@/app/lib/graphql/client'; +import { + GET_MY_NEW_CLIMB_SUBSCRIPTIONS, + type GetMyNewClimbSubscriptionsResponse, +} from '@/app/lib/graphql/operations/new-climb-feed'; interface HomePageContentProps { boardConfigs: BoardConfigData; @@ -28,15 +38,22 @@ export default function HomePageContent({ boardConfigs }: HomePageContentProps) const { data: session, status } = useSession(); const [searchOpen, setSearchOpen] = useState(false); const [selectedBoardUuid, setSelectedBoardUuid] = useState(null); + const [selectedBoard, setSelectedBoard] = useState(null); const [sortBy, setSortBy] = useState('new'); + const [activeTab, setActiveTab] = useState<'activity' | 'newClimbs'>('activity'); + const [subscriptions, setSubscriptions] = useState([]); const isAuthenticated = status === 'authenticated' && !!session?.user; + const { token: wsAuthToken } = useWsAuthToken(); - // Load persisted sort mode + // Load persisted sort mode and tab useEffect(() => { getPreference('activityFeedSortMode').then((saved) => { if (saved) setSortBy(saved); }); + getPreference<'activity' | 'newClimbs'>('homeTab').then((saved) => { + if (saved) setActiveTab(saved); + }); }, []); const handleSortChange = useCallback((newSort: SortMode) => { @@ -44,10 +61,38 @@ export default function HomePageContent({ boardConfigs }: HomePageContentProps) setPreference('activityFeedSortMode', newSort); }, []); + const handleTabChange = (_: React.SyntheticEvent, value: string) => { + const tab = value as 'activity' | 'newClimbs'; + setActiveTab(tab); + setPreference('homeTab', tab); + }; + const handleBoardFilter = useCallback((boardUuid: string | null) => { setSelectedBoardUuid(boardUuid); + if (!boardUuid) { + setSelectedBoard(null); + } }, []); + const handleBoardSelect = useCallback((board: UserBoard) => { + setSelectedBoard(board); + setSelectedBoardUuid(board.uuid); + }, []); + + useEffect(() => { + async function fetchSubscriptions() { + if (!isAuthenticated || !wsAuthToken) return; + try { + const client = createGraphQLHttpClient(wsAuthToken); + const res = await client.request(GET_MY_NEW_CLIMB_SUBSCRIPTIONS); + setSubscriptions(res.myNewClimbSubscriptions); + } catch (error) { + console.error('Failed to fetch new climb subscriptions', error); + } + } + fetchSubscriptions(); + }, [isAuthenticated, wsAuthToken]); + return ( {/* Header */} @@ -81,21 +126,58 @@ export default function HomePageContent({ boardConfigs }: HomePageContentProps) )} - - - Activity - - - - setSearchOpen(true)} - /> + + + + + + {activeTab === 'activity' && ( + <> + + + Activity + + + + setSearchOpen(true)} + /> + + )} + + {activeTab === 'newClimbs' && ( + + {selectedBoard ? ( + + sub.boardType === selectedBoard.boardType && + sub.layoutId === selectedBoard.layoutId, + )} + /> + ) : ( + + Select a board to see new climbs for its layout. + + )} + + )} {/* Bottom Bar: QueueControlBar (if active) + BottomTabBar */} diff --git a/packages/web/app/lib/graphql/operations/index.ts b/packages/web/app/lib/graphql/operations/index.ts index d0ea5d04e..eb344e0bb 100644 --- a/packages/web/app/lib/graphql/operations/index.ts +++ b/packages/web/app/lib/graphql/operations/index.ts @@ -7,3 +7,4 @@ export * from './comments-votes'; export * from './boards'; export * from './notifications'; export * from './activity-feed'; +export * from './new-climb-feed'; diff --git a/packages/web/app/lib/graphql/operations/new-climb-feed.ts b/packages/web/app/lib/graphql/operations/new-climb-feed.ts new file mode 100644 index 000000000..3d1cef844 --- /dev/null +++ b/packages/web/app/lib/graphql/operations/new-climb-feed.ts @@ -0,0 +1,140 @@ +import { gql } from 'graphql-request'; +import type { + NewClimbFeedInput, + NewClimbFeedResult, + NewClimbSubscription, + NewClimbSubscriptionInput, + NewClimbCreatedEvent, + SaveClimbInput, + SaveClimbResult, + SaveMoonBoardClimbInput, +} from '@boardsesh/shared-schema'; + +export const GET_NEW_CLIMB_FEED = gql` + query GetNewClimbFeed($input: NewClimbFeedInput!) { + newClimbFeed(input: $input) { + items { + uuid + name + boardType + layoutId + setterDisplayName + setterAvatarUrl + angle + frames + difficultyName + createdAt + } + totalCount + hasMore + } + } +`; + +export const GET_MY_NEW_CLIMB_SUBSCRIPTIONS = gql` + query GetMyNewClimbSubscriptions { + myNewClimbSubscriptions { + id + boardType + layoutId + createdAt + } + } +`; + +export const SUBSCRIBE_NEW_CLIMBS = gql` + mutation SubscribeNewClimbs($input: NewClimbSubscriptionInput!) { + subscribeNewClimbs(input: $input) + } +`; + +export const UNSUBSCRIBE_NEW_CLIMBS = gql` + mutation UnsubscribeNewClimbs($input: NewClimbSubscriptionInput!) { + unsubscribeNewClimbs(input: $input) + } +`; + +export const NEW_CLIMB_CREATED_SUBSCRIPTION = gql` + subscription OnNewClimbCreated($boardType: String!, $layoutId: Int!) { + newClimbCreated(boardType: $boardType, layoutId: $layoutId) { + climb { + uuid + name + boardType + layoutId + setterDisplayName + setterAvatarUrl + angle + frames + difficultyName + createdAt + } + } + } +`; + +export const SAVE_CLIMB_MUTATION = gql` + mutation SaveClimb($input: SaveClimbInput!) { + saveClimb(input: $input) { + uuid + synced + } + } +`; + +export const SAVE_MOONBOARD_CLIMB_MUTATION = gql` + mutation SaveMoonBoardClimb($input: SaveMoonBoardClimbInput!) { + saveMoonBoardClimb(input: $input) { + uuid + synced + } + } +`; + +export interface GetNewClimbFeedVariables { + input: NewClimbFeedInput; +} + +export interface GetNewClimbFeedResponse { + newClimbFeed: NewClimbFeedResult; +} + +export interface GetMyNewClimbSubscriptionsResponse { + myNewClimbSubscriptions: NewClimbSubscription[]; +} + +export interface SubscribeNewClimbsVariables { + input: NewClimbSubscriptionInput; +} + +export interface SubscribeNewClimbsResponse { + subscribeNewClimbs: boolean; +} + +export interface UnsubscribeNewClimbsVariables { + input: NewClimbSubscriptionInput; +} + +export interface UnsubscribeNewClimbsResponse { + unsubscribeNewClimbs: boolean; +} + +export interface NewClimbCreatedSubscriptionPayload { + newClimbCreated: NewClimbCreatedEvent; +} + +export interface SaveClimbMutationVariables { + input: SaveClimbInput; +} + +export interface SaveClimbMutationResponse { + saveClimb: SaveClimbResult; +} + +export interface SaveMoonBoardClimbMutationVariables { + input: SaveMoonBoardClimbInput; +} + +export interface SaveMoonBoardClimbMutationResponse { + saveMoonBoardClimb: SaveClimbResult; +}