All files / web/src/lib/auth mergeGuestIntoUser.ts

100% Statements 120/120
100% Branches 5/5
100% Functions 3/3
100% Lines 120/120

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 1211x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 11x 11x 11x 11x 11x 1x 1x 10x 10x 10x 10x 10x 470x 470x 470x 470x 10x 10x 10x 10x 90x 90x 90x 90x 90x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x  
import { eq, sql } from 'drizzle-orm'
import { db, schema } from '@/db'
 
/**
 * Merge a guest user's data into an existing authenticated user.
 *
 * This handles the "same person, different device" scenario:
 * User signs in on Device B with Google, but they already have an
 * authenticated account from Device A. Device B has a guest user row
 * with its own players/data that needs to be merged into the existing account.
 *
 * All data from sourceUserId is re-parented to targetUserId, then the
 * source user record is deleted.
 */
export async function mergeGuestIntoUser(
  sourceUserId: string,
  targetUserId: string
): Promise<void> {
  if (sourceUserId === targetUserId) {
    return
  }
 
  console.log(`[auth] merging guest user ${sourceUserId} into ${targetUserId}`)
 
  // Helper to re-parent rows from source to target in a given table/column
  async function reparent(table: string, column: string) {
    await db.run(
      sql`UPDATE ${sql.identifier(table)} SET ${sql.identifier(column)} = ${targetUserId} WHERE ${sql.identifier(column)} = ${sourceUserId}`
    )
  }
 
  // Helper for tables with unique constraint on user_id (one row per user).
  // Delete the guest's row if the target user already has one; otherwise re-parent.
  async function reparentOrDrop(table: string, column: string) {
    await db.run(
      sql`DELETE FROM ${sql.identifier(table)} WHERE ${sql.identifier(column)} = ${sourceUserId} AND EXISTS (SELECT 1 FROM ${sql.identifier(table)} AS t2 WHERE t2.${sql.identifier(column)} = ${targetUserId})`
    )
    await reparent(table, column)
  }
 
  // Tables with user_id FK (multi-row, no unique constraint on user_id)
  await reparent('players', 'user_id')
  await reparent('arcade_sessions', 'user_id')
  await reparent('custom_skills', 'user_id')
  await reparent('mcp_api_keys', 'user_id')
  await reparent('teacher_flowcharts', 'user_id')
  await reparent('skill_customizations', 'user_id')
  await reparent('workshop_sessions', 'user_id')
 
  // Arcade tables
  await reparent('arcade_rooms', 'created_by')
  await reparentOrDrop('room_members', 'user_id')
  await reparent('room_bans', 'user_id')
  await reparent('room_bans', 'banned_by')
  await reparent('room_reports', 'reporter_id')
  await reparent('room_reports', 'reported_user_id')
  await reparent('room_reports', 'reviewed_by')
  await reparent('room_invitations', 'user_id')
  await reparent('room_invitations', 'invited_by')
  await reparent('room_join_requests', 'user_id')
  await reparent('room_join_requests', 'reviewed_by')
  await reparent('room_member_history', 'user_id')
 
  // Worksheet tables
  await reparentOrDrop('worksheet_settings', 'user_id')
  await reparent('worksheet_mastery', 'user_id')
  await reparent('worksheet_attempts', 'user_id')
  await reparent('problem_attempts', 'user_id')
 
  // Game results and observation shares
  await reparent('game_results', 'user_id')
  await reparent('session_observation_shares', 'created_by')
  await reparent('session_songs', 'content_reviewed_by')
 
  // Tables with unique constraint on user_id (one settings row per user)
  // Prefer the target user's settings; drop the guest's if both exist
  await reparentOrDrop('abacus_settings', 'user_id')
  await reparentOrDrop('scanner_settings', 'user_id')
  await reparentOrDrop('user_stats', 'user_id')
  await reparentOrDrop('feature_flag_overrides', 'user_id')
  await reparentOrDrop('subscriptions', 'user_id')
 
  // Tables with differently named FK columns
  await reparent('classrooms', 'teacher_id')
  await reparent('parent_child', 'parent_user_id')
  await reparent('entry_prompts', 'teacher_id')
  await reparent('entry_prompts', 'responded_by')
  await reparent('practice_attachments', 'uploaded_by')
  await reparent('classroom_presence', 'entered_by')
  await reparent('enrollment_requests', 'requested_by')
  await reparent('enrollment_requests', 'parent_approved_by')
 
  // Family audit log
  await reparent('family_events', 'actor_user_id')
 
  // Household tables
  await reparent('household_members', 'user_id')
  await reparentOrDrop('households', 'owner_id')
 
  // Notification subscriptions
  await reparent('practice_notification_subscriptions', 'user_id')
 
  // Print service (Abacus Studio): paired connections and job ownership rows
  // must follow the person, or a guest's paired printer vanishes on sign-in.
  await reparent('print_service_connections', 'user_id')
  await reparent('print_jobs', 'user_id')
  await reparentOrDrop('abacus_print_settings', 'user_id')
  // Design snapshots (#22): an unshared design is owner-gated, so a guest's
  // ?design= links (printed on their THH job cards) must keep resolving after
  // sign-in — and this one line is also why sharing (#24) needed no merge code
  // of its own: the share flag rides this very row. The rows' content hashes
  // stay scoped to the OLD id — that only means a future identical submit
  // mints a fresh row instead of deduping, which is fine.
  await reparent('abacus_designs', 'created_by')
 
  // Delete the source user (CASCADE will clean up auth_accounts and any remaining FKs)
  await db.delete(schema.users).where(eq(schema.users.id, sourceUserId))
 
  console.log(`[auth] merge complete: ${sourceUserId} → ${targetUserId}`)
}