diff --git a/packages/db/drizzle/0026_migrate_aurora_to_boardsesh_ticks.sql b/packages/db/drizzle/0026_migrate_aurora_to_boardsesh_ticks.sql new file mode 100644 index 00000000..0cb51f2d --- /dev/null +++ b/packages/db/drizzle/0026_migrate_aurora_to_boardsesh_ticks.sql @@ -0,0 +1,187 @@ +-- Migration: Migrate Aurora ascents/bids to boardsesh_ticks +-- This migration is idempotent and only migrates users with NextAuth accounts + +-- Migrate historical Aurora data to unified boardsesh_ticks table +DO $$ +DECLARE + migrated_count integer; +BEGIN + -- Migrate Kilter Ascents (Flash/Send) + WITH kilter_ascents_to_migrate AS ( + SELECT + gen_random_uuid()::text AS uuid, + ac.user_id AS user_id, + 'kilter' AS board_type, + ka.climb_uuid, + ka.angle, + COALESCE(ka.is_mirror, false) AS is_mirror, + CASE + WHEN ka.attempt_id = 1 THEN 'flash'::tick_status + ELSE 'send'::tick_status + END AS status, + COALESCE(ka.bid_count, 1) AS attempt_count, + ROUND((ka.quality / 3.0) * 5)::integer AS quality, + ka.difficulty, + COALESCE(ka.is_benchmark::boolean, false) AS is_benchmark, + COALESCE(ka.comment, '') AS comment, + ka.climbed_at::timestamp AS climbed_at, + ka.created_at::timestamp AS created_at, + 'ascents'::aurora_table_type AS aurora_type, + ka.uuid AS aurora_id + FROM kilter_ascents ka + INNER JOIN aurora_credentials ac + ON ac.aurora_user_id = ka.user_id + AND ac.board_type = 'kilter' + WHERE NOT EXISTS ( + SELECT 1 FROM boardsesh_ticks bt + WHERE bt.aurora_id = ka.uuid + ) + ), + + -- Migrate Kilter Bids (Attempts) + kilter_bids_to_migrate AS ( + SELECT + gen_random_uuid()::text AS uuid, + ac.user_id AS user_id, + 'kilter' AS board_type, + kb.climb_uuid, + kb.angle, + COALESCE(kb.is_mirror, false) AS is_mirror, + 'attempt'::tick_status AS status, + COALESCE(kb.bid_count, 1) AS attempt_count, + NULL::integer AS quality, + NULL::integer AS difficulty, + false AS is_benchmark, + COALESCE(kb.comment, '') AS comment, + kb.climbed_at::timestamp AS climbed_at, + kb.created_at::timestamp AS created_at, + 'bids'::aurora_table_type AS aurora_type, + kb.uuid AS aurora_id + FROM kilter_bids kb + INNER JOIN aurora_credentials ac + ON ac.aurora_user_id = kb.user_id + AND ac.board_type = 'kilter' + WHERE NOT EXISTS ( + SELECT 1 FROM boardsesh_ticks bt + WHERE bt.aurora_id = kb.uuid + ) + ), + + -- Migrate Tension Ascents (Flash/Send) + tension_ascents_to_migrate AS ( + SELECT + gen_random_uuid()::text AS uuid, + ac.user_id AS user_id, + 'tension' AS board_type, + ta.climb_uuid, + ta.angle, + COALESCE(ta.is_mirror, false) AS is_mirror, + CASE + WHEN ta.attempt_id = 1 THEN 'flash'::tick_status + ELSE 'send'::tick_status + END AS status, + COALESCE(ta.bid_count, 1) AS attempt_count, + ROUND((ta.quality / 3.0) * 5)::integer AS quality, + ta.difficulty, + COALESCE(ta.is_benchmark::boolean, false) AS is_benchmark, + COALESCE(ta.comment, '') AS comment, + ta.climbed_at::timestamp AS climbed_at, + ta.created_at::timestamp AS created_at, + 'ascents'::aurora_table_type AS aurora_type, + ta.uuid AS aurora_id + FROM tension_ascents ta + INNER JOIN aurora_credentials ac + ON ac.aurora_user_id = ta.user_id + AND ac.board_type = 'tension' + WHERE NOT EXISTS ( + SELECT 1 FROM boardsesh_ticks bt + WHERE bt.aurora_id = ta.uuid + ) + ), + + -- Migrate Tension Bids (Attempts) + tension_bids_to_migrate AS ( + SELECT + gen_random_uuid()::text AS uuid, + ac.user_id AS user_id, + 'tension' AS board_type, + tb.climb_uuid, + tb.angle, + COALESCE(tb.is_mirror, false) AS is_mirror, + 'attempt'::tick_status AS status, + COALESCE(tb.bid_count, 1) AS attempt_count, + NULL::integer AS quality, + NULL::integer AS difficulty, + false AS is_benchmark, + COALESCE(tb.comment, '') AS comment, + tb.climbed_at::timestamp AS climbed_at, + tb.created_at::timestamp AS created_at, + 'bids'::aurora_table_type AS aurora_type, + tb.uuid AS aurora_id + FROM tension_bids tb + INNER JOIN aurora_credentials ac + ON ac.aurora_user_id = tb.user_id + AND ac.board_type = 'tension' + WHERE NOT EXISTS ( + SELECT 1 FROM boardsesh_ticks bt + WHERE bt.aurora_id = tb.uuid + ) + ), + + -- Union all migrations + all_ticks_to_migrate AS ( + SELECT * FROM kilter_ascents_to_migrate + UNION ALL + SELECT * FROM kilter_bids_to_migrate + UNION ALL + SELECT * FROM tension_ascents_to_migrate + UNION ALL + SELECT * FROM tension_bids_to_migrate + ) + + -- Insert into boardsesh_ticks + INSERT INTO boardsesh_ticks ( + uuid, + user_id, + board_type, + climb_uuid, + angle, + is_mirror, + status, + attempt_count, + quality, + difficulty, + is_benchmark, + comment, + climbed_at, + created_at, + updated_at, + aurora_type, + aurora_id, + aurora_synced_at + ) + SELECT + uuid, + user_id, + board_type, + climb_uuid, + angle, + is_mirror, + status, + attempt_count, + quality, + difficulty, + is_benchmark, + comment, + climbed_at, + created_at, + NOW(), -- updated_at + aurora_type, + aurora_id, + NOW() -- aurora_synced_at + FROM all_ticks_to_migrate; + + -- Log migration count + GET DIAGNOSTICS migrated_count = ROW_COUNT; + RAISE NOTICE 'Migrated % historical ticks from Aurora to boardsesh_ticks', migrated_count; +END $$; diff --git a/packages/web/app/api/internal/aurora-credentials/route.ts b/packages/web/app/api/internal/aurora-credentials/route.ts index 5cbc4994..9409b92a 100644 --- a/packages/web/app/api/internal/aurora-credentials/route.ts +++ b/packages/web/app/api/internal/aurora-credentials/route.ts @@ -8,6 +8,7 @@ import { authOptions } from "@/app/lib/auth/auth-options"; import { encrypt, decrypt } from "@/app/lib/crypto"; import AuroraClimbingClient from "@/app/lib/api-wrappers/aurora-rest-client/aurora-rest-client"; import { syncUserData } from "@/app/lib/data-sync/aurora/user-sync"; +import { migrateUserAuroraHistory } from "@/app/lib/data-sync/aurora/migrate-user-history"; import { BoardName } from "@/app/lib/types"; const saveCredentialsSchema = z.object({ @@ -207,15 +208,23 @@ export async function POST(request: NextRequest) { // Trigger sync in background try { + // First sync ongoing data await syncUserData(boardType as BoardName, loginResponse.token, loginResponse.user_id); + + // Then migrate historical data + await migrateUserAuroraHistory( + session.user.id, // NextAuth user ID + boardType as BoardName, + loginResponse.user_id // Aurora user ID + ); } catch (syncError) { - console.error("Sync error (non-blocking):", syncError); + console.error("Sync/migration error (non-blocking):", syncError); // Update sync status to reflect error await db .update(schema.auroraCredentials) .set({ syncStatus: "error", - syncError: syncError instanceof Error ? syncError.message : "Sync failed", + syncError: syncError instanceof Error ? syncError.message : "Sync/migration failed", updatedAt: new Date(), }) .where( diff --git a/packages/web/app/api/internal/migrate-users-cron/route.ts b/packages/web/app/api/internal/migrate-users-cron/route.ts new file mode 100644 index 00000000..eda11518 --- /dev/null +++ b/packages/web/app/api/internal/migrate-users-cron/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from 'next/server'; +import { migrateUserAuroraHistory } from '@/app/lib/data-sync/aurora/migrate-user-history'; +import { getPool } from '@/app/lib/db/db'; +import { drizzle } from 'drizzle-orm/neon-serverless'; +import { eq, sql } from 'drizzle-orm'; +import { BoardName } from '@/app/lib/types'; +import * as schema from '@/app/lib/db/schema'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; // 5 minutes max + +const CRON_SECRET = process.env.CRON_SECRET; + +interface MigrationResult { + userId: string; + boardType: string; + migrated?: number; + error?: string; +} + +export async function GET(request: Request) { + try { + // Auth check: skip in development only, require secret in all other environments + const authHeader = request.headers.get('authorization'); + const isDevelopment = process.env.VERCEL_ENV === 'development' || process.env.NODE_ENV === 'development'; + + if (!isDevelopment && authHeader !== `Bearer ${CRON_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const pool = getPool(); + + // Find users with aurora_credentials but no migrated ticks + let unmigratedUsers; + { + const client = await pool.connect(); + try { + const result = await client.query(` + SELECT DISTINCT + ac.user_id, + ac.board_type, + ac.aurora_user_id + FROM aurora_credentials ac + WHERE ac.sync_status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM boardsesh_ticks bt + WHERE bt.user_id = ac.user_id + AND bt.board_type = ac.board_type + AND bt.aurora_id IS NOT NULL + ) + LIMIT 10 + `); + unmigratedUsers = result.rows; + } finally { + client.release(); + } + } + + console.log(`[Migrate Users Cron] Found ${unmigratedUsers.length} users with unmigrated Aurora data`); + + const results = { + total: unmigratedUsers.length, + successful: 0, + failed: 0, + totalMigrated: 0, + errors: [] as MigrationResult[], + }; + + // Migrate each user sequentially + for (const user of unmigratedUsers) { + try { + console.log(`[Migrate Users Cron] Migrating user ${user.user_id} (${user.board_type})`); + + const result = await migrateUserAuroraHistory( + user.user_id, + user.board_type as BoardName, + user.aurora_user_id + ); + + results.successful++; + results.totalMigrated += result.migrated; + + console.log(`[Migrate Users Cron] Successfully migrated ${result.migrated} ticks for user ${user.user_id}`); + } catch (error) { + results.failed++; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[Migrate Users Cron] Failed to migrate user ${user.user_id}:`, errorMessage); + + results.errors.push({ + userId: user.user_id, + boardType: user.board_type, + error: errorMessage, + }); + + // Update sync status in aurora_credentials to reflect error + const client = await pool.connect(); + try { + const db = drizzle(client); + await db + .update(schema.auroraCredentials) + .set({ + syncStatus: 'error', + syncError: `Migration failed: ${errorMessage}`, + updatedAt: new Date(), + }) + .where( + sql`${schema.auroraCredentials.userId} = ${user.user_id} AND ${schema.auroraCredentials.boardType} = ${user.board_type}` + ); + } catch (updateError) { + console.error(`[Migrate Users Cron] Failed to update error status:`, updateError); + } finally { + client.release(); + } + } + } + + console.log( + `[Migrate Users Cron] Completed: ${results.successful}/${results.total} users, ${results.totalMigrated} ticks migrated` + ); + + return NextResponse.json(results); + } catch (error) { + console.error('[Migrate Users Cron] Fatal error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/packages/web/app/lib/data-sync/aurora/convert-quality.ts b/packages/web/app/lib/data-sync/aurora/convert-quality.ts new file mode 100644 index 00000000..f6fe29c5 --- /dev/null +++ b/packages/web/app/lib/data-sync/aurora/convert-quality.ts @@ -0,0 +1,11 @@ +/** + * Convert Aurora quality (1-5) to Boardsesh quality (1-5) + * Formula: quality / 3.0 * 5 + * + * Aurora uses a 1-3 scale (with some 4-5 values), while Boardsesh uses a 1-5 scale. + * This conversion scales the Aurora rating to match the Boardsesh scale. + */ +export function convertQuality(auroraQuality: number | null | undefined): number | null { + if (auroraQuality == null) return null; + return Math.round((auroraQuality / 3.0) * 5); +} diff --git a/packages/web/app/lib/data-sync/aurora/migrate-user-history.ts b/packages/web/app/lib/data-sync/aurora/migrate-user-history.ts new file mode 100644 index 00000000..7b698184 --- /dev/null +++ b/packages/web/app/lib/data-sync/aurora/migrate-user-history.ts @@ -0,0 +1,168 @@ +import { getPool } from '@/app/lib/db/db'; +import { BoardName } from '../../types'; +import { drizzle } from 'drizzle-orm/neon-serverless'; +import { getTable } from '../../db/queries/util/table-select'; +import { boardseshTicks } from '../../db/schema'; +import { randomUUID } from 'crypto'; +import { eq, and, isNotNull } from 'drizzle-orm'; +import { convertQuality } from './convert-quality'; + +/** + * Migrate a single user's historical Aurora data to boardsesh_ticks + * This function is called when: + * 1. User adds Aurora credentials (user-triggered) + * 2. Background cron job finds unmigrated users (cron-triggered) + * + * @param nextAuthUserId - NextAuth user ID + * @param boardType - Board type ('kilter' or 'tension') + * @param auroraUserId - Aurora user ID + * @returns Object with migrated count + */ +export async function migrateUserAuroraHistory( + nextAuthUserId: string, + boardType: BoardName, + auroraUserId: number, +): Promise<{ migrated: number }> { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const db = drizzle(client); + + // Use advisory lock to prevent concurrent migrations for same user+board + // Hash the user ID and board type to create a unique lock ID + const lockId = `${nextAuthUserId}-${boardType}`; + const lockHash = lockId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const acquired = await client.query('SELECT pg_try_advisory_xact_lock($1)', [lockHash]); + + if (!acquired.rows[0].pg_try_advisory_xact_lock) { + console.log(`Migration already in progress for user ${nextAuthUserId} (${boardType}), skipping`); + await client.query('ROLLBACK'); + return { migrated: 0 }; + } + + let totalMigrated = 0; + + // Check if user already has migrated data + const existingTicks = await db + .select({ count: boardseshTicks.id }) + .from(boardseshTicks) + .where( + and( + eq(boardseshTicks.userId, nextAuthUserId), + eq(boardseshTicks.boardType, boardType), + isNotNull(boardseshTicks.auroraId) + ) + ) + .limit(1); + + if (existingTicks.length > 0) { + console.log(`User ${nextAuthUserId} already has migrated data for ${boardType}, skipping`); + await client.query('COMMIT'); + return { migrated: 0 }; + } + + // Migrate ascents (successful climbs) + const ascentsSchema = getTable('ascents', boardType); + const ascents = await db + .select() + .from(ascentsSchema) + .where(eq(ascentsSchema.userId, auroraUserId)); + + // Prepare batch insert values for ascents + const ascentValues = []; + for (const ascent of ascents) { + // Skip if missing required fields + if (!ascent.climbUuid || !ascent.climbedAt) { + console.warn(`Skipping ascent ${ascent.uuid} - missing required fields`); + continue; + } + + const status = Number(ascent.attemptId) === 1 ? ('flash' as const) : ('send' as const); + const convertedQuality = convertQuality(ascent.quality); + + ascentValues.push({ + uuid: randomUUID(), + userId: nextAuthUserId, + boardType: boardType, + climbUuid: ascent.climbUuid, + angle: Number(ascent.angle), + isMirror: Boolean(ascent.isMirror), + status: status, + attemptCount: Number(ascent.bidCount || 1), + quality: convertedQuality, + difficulty: ascent.difficulty ? Number(ascent.difficulty) : null, + isBenchmark: Boolean(ascent.isBenchmark || 0), + comment: ascent.comment || '', + climbedAt: new Date(ascent.climbedAt).toISOString(), + createdAt: ascent.createdAt ? new Date(ascent.createdAt).toISOString() : new Date().toISOString(), + updatedAt: new Date().toISOString(), + auroraType: 'ascents' as const, + auroraId: ascent.uuid, + auroraSyncedAt: new Date().toISOString(), + }); + } + + // Batch insert ascents (if any) + if (ascentValues.length > 0) { + await db.insert(boardseshTicks).values(ascentValues); + totalMigrated += ascentValues.length; + } + + // Migrate bids (failed attempts) + const bidsSchema = getTable('bids', boardType); + const bids = await db + .select() + .from(bidsSchema) + .where(eq(bidsSchema.userId, auroraUserId)); + + // Prepare batch insert values for bids + const bidValues = []; + for (const bid of bids) { + // Skip if missing required fields + if (!bid.climbUuid || !bid.climbedAt) { + console.warn(`Skipping bid ${bid.uuid} - missing required fields`); + continue; + } + + bidValues.push({ + uuid: randomUUID(), + userId: nextAuthUserId, + boardType: boardType, + climbUuid: bid.climbUuid, + angle: Number(bid.angle), + isMirror: Boolean(bid.isMirror), + status: 'attempt' as const, + attemptCount: Number(bid.bidCount || 1), + quality: null, + difficulty: null, + isBenchmark: false, + comment: bid.comment || '', + climbedAt: new Date(bid.climbedAt).toISOString(), + createdAt: bid.createdAt ? new Date(bid.createdAt).toISOString() : new Date().toISOString(), + updatedAt: new Date().toISOString(), + auroraType: 'bids' as const, + auroraId: bid.uuid, + auroraSyncedAt: new Date().toISOString(), + }); + } + + // Batch insert bids (if any) + if (bidValues.length > 0) { + await db.insert(boardseshTicks).values(bidValues); + totalMigrated += bidValues.length; + } + + await client.query('COMMIT'); + console.log(`Migrated ${totalMigrated} historical ticks for user ${nextAuthUserId} on ${boardType}`); + + return { migrated: totalMigrated }; + } catch (error) { + await client.query('ROLLBACK'); + console.error('Failed to migrate user Aurora history:', error); + throw error; + } finally { + client.release(); + } +} diff --git a/packages/web/app/lib/data-sync/aurora/user-sync.ts b/packages/web/app/lib/data-sync/aurora/user-sync.ts index a4a39663..439a1cf1 100644 --- a/packages/web/app/lib/data-sync/aurora/user-sync.ts +++ b/packages/web/app/lib/data-sync/aurora/user-sync.ts @@ -6,12 +6,33 @@ import { eq, and, inArray } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/neon-serverless'; import { NeonDatabase } from 'drizzle-orm/neon-serverless'; import { getTable } from '../../db/queries/util/table-select'; +import { boardseshTicks, auroraCredentials } from '../../db/schema'; +import { randomUUID } from 'crypto'; +import { convertQuality } from './convert-quality'; + +/** + * Get NextAuth user ID from Aurora user ID + */ +async function getNextAuthUserId( + db: NeonDatabase>, + boardName: BoardName, + auroraUserId: number, +): Promise { + const result = await db + .select({ userId: auroraCredentials.userId }) + .from(auroraCredentials) + .where(and(eq(auroraCredentials.boardType, boardName), eq(auroraCredentials.auroraUserId, auroraUserId))) + .limit(1); + + return result[0]?.userId || null; +} async function upsertTableData( db: NeonDatabase>, boardName: BoardName, tableName: string, - userId: number, + auroraUserId: number, + nextAuthUserId: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any[], ) { @@ -45,7 +66,7 @@ async function upsertTableData( .insert(wallsSchema) .values({ uuid: item.uuid, - userId: Number(userId), + userId: Number(auroraUserId), name: item.name, productId: Number(item.product_id), isAdjustable: Boolean(item.is_adjustable), @@ -80,7 +101,7 @@ async function upsertTableData( .values({ uuid: item.uuid, layoutId: Number(item.layout_id), - setterId: Number(userId), + setterId: Number(auroraUserId), setterUsername: item.setter_username || '', name: item.name || 'Untitled Draft', description: item.description || '', @@ -101,7 +122,7 @@ async function upsertTableData( target: climbsSchema.uuid, set: { layoutId: Number(item.layout_id), - setterId: Number(userId), + setterId: Number(auroraUserId), setterUsername: item.setter_username || '', name: item.name || 'Untitled Draft', description: item.description || '', @@ -125,6 +146,7 @@ async function upsertTableData( case 'ascents': { const ascentsSchema = getTable('ascents', boardName); for (const item of data) { + // Write to Aurora table await db .insert(ascentsSchema) .values({ @@ -132,7 +154,7 @@ async function upsertTableData( climbUuid: item.climb_uuid, angle: Number(item.angle), isMirror: Boolean(item.is_mirror), - userId: Number(userId), + userId: Number(auroraUserId), attemptId: Number(item.attempt_id), bidCount: Number(item.bid_count || 1), quality: Number(item.quality), @@ -157,6 +179,52 @@ async function upsertTableData( climbedAt: item.climbed_at, }, }); + + // Dual write to boardsesh_ticks (only if we have NextAuth user ID) + if (nextAuthUserId) { + const status = Number(item.attempt_id) === 1 ? 'flash' : 'send'; + const convertedQuality = convertQuality(item.quality); + + await db + .insert(boardseshTicks) + .values({ + uuid: randomUUID(), + userId: nextAuthUserId, + boardType: boardName, + climbUuid: item.climb_uuid, + angle: Number(item.angle), + isMirror: Boolean(item.is_mirror), + status: status, + attemptCount: Number(item.bid_count || 1), + quality: convertedQuality, + difficulty: item.difficulty ? Number(item.difficulty) : null, + isBenchmark: Boolean(item.is_benchmark || 0), + comment: item.comment || '', + climbedAt: new Date(item.climbed_at).toISOString(), + createdAt: item.created_at ? new Date(item.created_at).toISOString() : new Date().toISOString(), + updatedAt: new Date().toISOString(), + auroraType: 'ascents', + auroraId: item.uuid, + auroraSyncedAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: boardseshTicks.auroraId, + set: { + climbUuid: item.climb_uuid, + angle: Number(item.angle), + isMirror: Boolean(item.is_mirror), + status: status, + attemptCount: Number(item.bid_count || 1), + quality: convertedQuality, + difficulty: item.difficulty ? Number(item.difficulty) : null, + isBenchmark: Boolean(item.is_benchmark || 0), + comment: item.comment || '', + climbedAt: new Date(item.climbed_at).toISOString(), + updatedAt: new Date().toISOString(), + auroraSyncedAt: new Date().toISOString(), + }, + }); + } } break; } @@ -164,11 +232,12 @@ async function upsertTableData( case 'bids': { const bidsSchema = getTable('bids', boardName); for (const item of data) { + // Write to Aurora table await db .insert(bidsSchema) .values({ uuid: item.uuid, - userId: Number(userId), + userId: Number(auroraUserId), climbUuid: item.climb_uuid, angle: Number(item.angle), isMirror: Boolean(item.is_mirror), @@ -188,6 +257,45 @@ async function upsertTableData( climbedAt: item.climbed_at, }, }); + + // Dual write to boardsesh_ticks (only if we have NextAuth user ID) + if (nextAuthUserId) { + await db + .insert(boardseshTicks) + .values({ + uuid: randomUUID(), + userId: nextAuthUserId, + boardType: boardName, + climbUuid: item.climb_uuid, + angle: Number(item.angle), + isMirror: Boolean(item.is_mirror), + status: 'attempt', + attemptCount: Number(item.bid_count || 1), + quality: null, + difficulty: null, + isBenchmark: false, + comment: item.comment || '', + climbedAt: new Date(item.climbed_at).toISOString(), + createdAt: new Date(item.created_at).toISOString(), + updatedAt: new Date().toISOString(), + auroraType: 'bids', + auroraId: item.uuid, + auroraSyncedAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: boardseshTicks.auroraId, + set: { + climbUuid: item.climb_uuid, + angle: Number(item.angle), + isMirror: Boolean(item.is_mirror), + attemptCount: Number(item.bid_count || 1), + comment: item.comment || '', + climbedAt: new Date(item.climbed_at).toISOString(), + updatedAt: new Date().toISOString(), + auroraSyncedAt: new Date().toISOString(), + }, + }); + } } break; } @@ -204,7 +312,7 @@ async function upsertTableData( .where( and( eq(tagsSchema.entityUuid, item.entity_uuid), - eq(tagsSchema.userId, Number(userId)), + eq(tagsSchema.userId, Number(auroraUserId)), eq(tagsSchema.name, item.name), ), ) @@ -214,7 +322,7 @@ async function upsertTableData( if (result.length === 0) { await db.insert(tagsSchema).values({ entityUuid: item.entity_uuid, - userId: Number(userId), + userId: Number(auroraUserId), name: item.name, isListed: Boolean(item.is_listed), }); @@ -233,7 +341,7 @@ async function upsertTableData( name: item.name, description: item.description, color: item.color, - userId: Number(userId), + userId: Number(auroraUserId), isPublic: Boolean(item.is_public), createdAt: item.created_at, updatedAt: item.updated_at, @@ -368,12 +476,26 @@ export async function syncUserData( // Create a drizzle instance for this transaction const tx = drizzle(client); + // Get NextAuth user ID for dual write to boardsesh_ticks + const nextAuthUserId = await getNextAuthUserId(tx, board, userId); + if (!nextAuthUserId) { + console.warn(`No NextAuth user found for Aurora user ${userId} on ${board}, skipping ascents/bids sync`); + // We can still sync other tables (users, walls, etc.) that don't need NextAuth user ID + } + // Process each table - data is directly under table names for (const tableName of tables) { console.log(`Syncing ${tableName} for user ${userId} (batch ${syncAttempts})`); if (syncResults[tableName] && Array.isArray(syncResults[tableName])) { const data = syncResults[tableName]; - await upsertTableData(tx, board, tableName, userId, data); + + // Skip ascents/bids if no NextAuth user (can't dual write) + if ((tableName === 'ascents' || tableName === 'bids') && !nextAuthUserId) { + console.warn(`Skipping ${tableName} sync for Aurora user ${userId} - no NextAuth mapping`); + continue; + } + + await upsertTableData(tx, board, tableName, userId, nextAuthUserId || '', data); // Accumulate results if (!totalResults[tableName]) { diff --git a/vercel.json b/vercel.json index 94ac729e..bf4ec403 100644 --- a/vercel.json +++ b/vercel.json @@ -15,6 +15,10 @@ { "path": "/api/internal/user-sync-cron", "schedule": "0 */6 * * *" + }, + { + "path": "/api/internal/migrate-users-cron", + "schedule": "0 3 * * *" } ] }