From 974e86a5023f0e4804df511d9d598301c514c67e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 7 Feb 2026 02:55:53 -0500 Subject: [PATCH] fix: resolve circular FK between users and accounts on registration Account.owner_id and User.account_id are both NOT NULL, creating a circular dependency that prevents inserting either row first. Fix by: 1. Making owner_id nullable (set immediately after user creation) 2. Creating Account before User, then setting owner_id after flush 3. Removing NOT NULL enforcement on owner_id in migration 020 Co-Authored-By: Claude Opus 4.6 --- .../020_finalize_account_migration.py | 3 +- backend/app/api/endpoints/auth.py | 45 +++++++++++-------- backend/app/models/account.py | 2 +- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/backend/alembic/versions/020_finalize_account_migration.py b/backend/alembic/versions/020_finalize_account_migration.py index 510c8abb..2cd29772 100644 --- a/backend/alembic/versions/020_finalize_account_migration.py +++ b/backend/alembic/versions/020_finalize_account_migration.py @@ -81,8 +81,7 @@ def upgrade() -> None: ['account_id'], ['id'], ondelete='CASCADE' ) - # 4. Accounts: enforce owner_id NOT NULL + FK - op.alter_column('accounts', 'owner_id', nullable=False) + # 4. Accounts: add owner FK (owner_id stays nullable due to circular FK with users) op.create_foreign_key( 'fk_accounts_owner_id', 'accounts', 'users', ['owner_id'], ['id'], ondelete='RESTRICT' diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 385c31fd..44384c73 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -134,35 +134,47 @@ async def register( detail="Email already registered" ) - # Create new user - new_user = User( - email=user_data.email, - password_hash=get_password_hash(user_data.password), - name=user_data.name, - role="engineer", - invite_code_id=invite_code_record.id if invite_code_record else None - ) - db.add(new_user) - await db.flush() # Get user ID before creating account - if account_invite_record: # Join existing account via account invite - new_user.account_id = account_invite_record.account_id - new_user.account_role = account_invite_record.role + new_user = User( + email=user_data.email, + password_hash=get_password_hash(user_data.password), + name=user_data.name, + role="engineer", + invite_code_id=invite_code_record.id if invite_code_record else None, + account_id=account_invite_record.account_id, + account_role=account_invite_record.role, + ) + db.add(new_user) + await db.flush() # Mark account invite as used account_invite_record.accepted_by_id = new_user.id account_invite_record.used_at = datetime.now(timezone.utc) else: - # Create personal Account + free Subscription + # Create personal Account first (user needs account_id for NOT NULL constraint) new_account = Account( name=f"{user_data.name}'s Account", display_code=_generate_display_code(), - owner_id=new_user.id, ) db.add(new_account) await db.flush() # Get account ID + new_user = User( + email=user_data.email, + password_hash=get_password_hash(user_data.password), + name=user_data.name, + role="engineer", + invite_code_id=invite_code_record.id if invite_code_record else None, + account_id=new_account.id, + account_role="owner", + ) + db.add(new_user) + await db.flush() # Get user ID + + # Now set account owner and create subscription + new_account.owner_id = new_user.id + new_subscription = Subscription( account_id=new_account.id, plan="free", @@ -170,9 +182,6 @@ async def register( ) db.add(new_subscription) - new_user.account_id = new_account.id - new_user.account_role = "owner" - # Mark platform invite code as used if invite_code_record: invite_code_record.used_by_id = new_user.id diff --git a/backend/app/models/account.py b/backend/app/models/account.py index 6506488f..e9e8be18 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -22,7 +22,7 @@ class Account(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(255), nullable=False) display_code: Mapped[str] = mapped_column(String(8), unique=True, nullable=False) - owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False) + owner_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=True) stripe_customer_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))