#!/usr/bin/env python3 """ P0 CRITICAL - The Great Garage & Vehicle Migration (Legacy Data Cleanup) Migrates legacy person_id-based ownership to the new organization_id / organization_members RBAC structure. Ensures every user has a personal garage (INDIVIDUAL org), is bound as OWNER in organization_members, and orphaned vehicles are rescued into their owner's garage. TASKS: A) Ensure Personal Garages exist for every user who lacks one B) Bind users to their garages via organization_members (OWNER role) + update scope_id C) Rescue orphaned vehicles (owner_person_id set but no current_organization_id/owner_org_id) Usage: docker exec sf_api python3 /app/app/scripts/migrate_legacy_garages.py """ import asyncio import asyncpg import json import os import sys from datetime import datetime, timezone # asyncpg needs plain "postgresql://" scheme, not "postgresql+asyncpg://" _raw_url = os.getenv( "DATABASE_URL", "postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder" ) DATABASE_URL = _raw_url.replace("+asyncpg", "") async def main(): print("=" * 70) print("πŸ”§ P0 MIGRATION: THE GREAT GARAGE & VEHICLE MIGRATION") print("=" * 70) print(f"Started at: {datetime.now(timezone.utc).isoformat()}") print() conn = await asyncpg.connect(DATABASE_URL) try: # ── TASK A: ENSURE PERSONAL GARAGES EXIST ── print("πŸ“‹ TASK A: Ensure Personal Garages Exist") print("-" * 50) # Find all users who do NOT have an INDIVIDUAL-type organization where they are owner users_without_garage = await conn.fetch(""" SELECT u.id AS user_id, u.email, p.id AS person_id, p.first_name, p.last_name FROM identity.users u JOIN identity.persons p ON p.id = u.person_id WHERE u.is_deleted = false AND u.is_active = true AND NOT EXISTS ( SELECT 1 FROM fleet.organizations o WHERE o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false ) ORDER BY u.id """) print(f" Found {len(users_without_garage)} users without a personal garage.") garages_created = 0 for row in users_without_garage: user_id = row['user_id'] person_id = row['person_id'] first_name = row['first_name'] or "FelhasznΓ‘lΓ³" last_name = row['last_name'] or "" garage_name = f"{first_name} GarΓ‘zsa" folder_slug = f"personal-{user_id}-{person_id}" now = datetime.now(timezone.utc) # Direct INSERT with all NOT NULL columns explicitly provided await conn.execute(""" INSERT INTO fleet.organizations (full_name, name, display_name, folder_slug, org_type, owner_id, status, is_active, is_deleted, subscription_plan, base_asset_limit, purchased_extra_slots, notification_settings, external_integration_config, is_ownership_transferable, is_verified, first_registered_at, current_lifecycle_started_at, lifecycle_index, created_at, visual_settings, aliases, tags, settings) VALUES ($1, $2, $3, $4, 'individual', $5, 'active', true, false, 'FREE', 5, 0, $6::json, '{}'::json, true, false, $7, $8, 1, $9, $10::jsonb, '[]'::jsonb, '[]'::jsonb, $11::jsonb) """, garage_name, # $1 full_name garage_name, # $2 name garage_name, # $3 display_name folder_slug, # $4 folder_slug user_id, # $5 owner_id json.dumps({"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}), # $6 notification_settings now, # $7 first_registered_at now, # $8 current_lifecycle_started_at now, # $9 created_at json.dumps({"theme": "default", "primary_color": None, "wall_logo_url": None}), # $10 visual_settings json.dumps({"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}), # $11 settings ) garages_created += 1 print(f" βœ… Created personal garage '{garage_name}' for user_id={user_id} ({row['email']})") print(f"\n πŸ“Š Task A Result: {garages_created} personal garages created.") print() # ── TASK B: BIND USERS TO GARAGES (RBAC) ── print("πŸ“‹ TASK B: Bind Users to Garages (RBAC)") print("-" * 50) # Get all active users with their personal garages users_and_garages = await conn.fetch(""" SELECT u.id AS user_id, u.email, u.scope_id, o.id AS org_id, o.name AS org_name FROM identity.users u JOIN fleet.organizations o ON o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false WHERE u.is_deleted = false AND u.is_active = true ORDER BY u.id """) members_added = 0 scopes_updated = 0 for row in users_and_garages: user_id = row['user_id'] org_id = row['org_id'] scope_id = row['scope_id'] # Check if user is already a member of this org existing_member = await conn.fetchrow(""" SELECT id FROM fleet.organization_members WHERE organization_id = $1 AND user_id = $2 """, org_id, user_id) if not existing_member: await conn.execute(""" INSERT INTO fleet.organization_members (organization_id, user_id, role, status, is_permanent, is_verified, created_at) VALUES ($1, $2, 'OWNER', 'active', true, true, NOW()) """, org_id, user_id) members_added += 1 print(f" βœ… Added user_id={user_id} as OWNER of org_id={org_id} ({row['org_name']})") else: # Ensure role is OWNER await conn.execute(""" UPDATE fleet.organization_members SET role = 'OWNER', is_permanent = true, is_verified = true, status = 'active' WHERE organization_id = $1 AND user_id = $2 """, org_id, user_id) print(f" ℹ️ Updated membership for user_id={user_id} in org_id={org_id} to OWNER") # Update scope_id if NULL if scope_id is None or scope_id == "": await conn.execute(""" UPDATE identity.users SET scope_id = $1::text WHERE id = $2 """, str(org_id), user_id) scopes_updated += 1 print(f" βœ… Updated scope_id for user_id={user_id} to '{org_id}'") print(f"\n πŸ“Š Task B Result: {members_added} members added, {scopes_updated} scope_ids updated.") print() # ── TASK C: RESCUE ORPHANED VEHICLES ── print("πŸ“‹ TASK C: Rescue Orphaned Vehicles") print("-" * 50) # Find orphaned vehicles: have owner_person_id but no current_organization_id or owner_org_id orphaned_vehicles = await conn.fetch(""" SELECT a.id AS asset_id, a.license_plate, a.vin, a.owner_person_id, a.current_organization_id, a.owner_org_id, a.brand, a.model FROM vehicle.assets a WHERE a.owner_person_id IS NOT NULL AND (a.current_organization_id IS NULL OR a.owner_org_id IS NULL) AND a.status != 'deleted' ORDER BY a.owner_person_id, a.id """) print(f" Found {len(orphaned_vehicles)} orphaned vehicles.") vehicles_rescued = 0 vehicles_skipped = 0 for vehicle in orphaned_vehicles: owner_person_id = vehicle['owner_person_id'] asset_id = vehicle['asset_id'] plate = vehicle['license_plate'] or vehicle['vin'] or str(asset_id) # Find the user who owns this person record user_row = await conn.fetchrow(""" SELECT u.id AS user_id FROM identity.users u WHERE u.person_id = $1 AND u.is_deleted = false AND u.is_active = true LIMIT 1 """, owner_person_id) if not user_row: print(f" ⚠️ No active user found for person_id={owner_person_id}, skipping vehicle {plate}") vehicles_skipped += 1 continue user_id = user_row['user_id'] # Find the user's personal garage garage = await conn.fetchrow(""" SELECT o.id AS org_id FROM fleet.organizations o WHERE o.owner_id = $1 AND o.org_type = 'individual' AND o.is_deleted = false LIMIT 1 """, user_id) if not garage: print(f" ⚠️ No personal garage found for user_id={user_id}, skipping vehicle {plate}") vehicles_skipped += 1 continue org_id = garage['org_id'] # Update the vehicle's organization fields updates = [] params = [] param_idx = 1 if vehicle['current_organization_id'] is None: updates.append(f"current_organization_id = ${param_idx}") params.append(org_id) param_idx += 1 if vehicle['owner_org_id'] is None: updates.append(f"owner_org_id = ${param_idx}") params.append(org_id) param_idx += 1 if updates: params.append(asset_id) update_sql = f""" UPDATE vehicle.assets SET {', '.join(updates)} WHERE id = ${param_idx}::uuid """ await conn.execute(update_sql, *params) vehicles_rescued += 1 print(f" βœ… Rescued vehicle {plate} β†’ org_id={org_id} (user_id={user_id})") print(f"\n πŸ“Š Task C Result: {vehicles_rescued} vehicles rescued, {vehicles_skipped} skipped.") print() # ── FINAL REPORT ── print("=" * 70) print("πŸ“Š FINAL MIGRATION REPORT") print("=" * 70) print(f" πŸ—οΈ Task A - New Garages Created: {garages_created}") print(f" πŸ‘₯ Task B - Members Added: {members_added}") print(f" πŸ†” Task B - Scope IDs Updated: {scopes_updated}") print(f" πŸš— Task C - Vehicles Rescued: {vehicles_rescued}") print(f" ⏭️ Task C - Vehicles Skipped: {vehicles_skipped}") print() print(f"Completed at: {datetime.now(timezone.utc).isoformat()}") print("=" * 70) except Exception as e: print(f"\n❌ FATAL ERROR: {e}") import traceback traceback.print_exc() sys.exit(1) finally: await conn.close() if __name__ == "__main__": asyncio.run(main())