Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions packages/db/drizzle/0026_migrate_aurora_to_boardsesh_ticks.sql
Original file line number Diff line number Diff line change
@@ -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 $$;
13 changes: 11 additions & 2 deletions packages/web/app/api/internal/aurora-credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand Down
129 changes: 129 additions & 0 deletions packages/web/app/api/internal/migrate-users-cron/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
11 changes: 11 additions & 0 deletions packages/web/app/lib/data-sync/aurora/convert-quality.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading