From c2aa62fa036dd302f530e68b9969efd0d3c4176c Mon Sep 17 00:00:00 2001
From: mohamad <mohamad>
Date: Fri, 16 May 2025 22:31:44 +0200
Subject: [PATCH] Update user model migration to set invalid password
 placeholder; enhance invite management with new endpoints for active invites
 and improved error handling in group invite creation. Refactor frontend to
 fetch and display active invite codes.

---
 ...0fc_update_user_model_for_fastapi_users.py |   2 +-
 be/app/api/auth/oauth.py                      |   6 +-
 be/app/api/v1/endpoints/groups.py             |  48 +++++-
 be/app/crud/invite.py                         | 138 ++++++++++++------
 fe/src/config/api-config.ts                   |   2 +
 fe/src/pages/GroupDetailPage.vue              |  54 +++++--
 6 files changed, 190 insertions(+), 60 deletions(-)

diff --git a/be/alembic/versions/5e8b6dde50fc_update_user_model_for_fastapi_users.py b/be/alembic/versions/5e8b6dde50fc_update_user_model_for_fastapi_users.py
index 667c3c8..195ebc0 100644
--- a/be/alembic/versions/5e8b6dde50fc_update_user_model_for_fastapi_users.py
+++ b/be/alembic/versions/5e8b6dde50fc_update_user_model_for_fastapi_users.py
@@ -27,7 +27,7 @@ def upgrade() -> None:
     op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false()))
 
     # 2. Set default values for existing rows
-    op.execute("UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL")
+    op.execute("UPDATE users SET hashed_password = '$INVALID_PASSWORD_PLACEHOLDER$' WHERE hashed_password IS NULL")
     op.execute("UPDATE users SET is_active = true WHERE is_active IS NULL")
     op.execute("UPDATE users SET is_superuser = false WHERE is_superuser IS NULL")
     op.execute("UPDATE users SET is_verified = false WHERE is_verified IS NULL")
diff --git a/be/app/api/auth/oauth.py b/be/app/api/auth/oauth.py
index 7d03a7b..211b664 100644
--- a/be/app/api/auth/oauth.py
+++ b/be/app/api/auth/oauth.py
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from app.database import get_async_session
 from app.models import User
-from app.auth import oauth, fastapi_users
+from app.auth import oauth, fastapi_users, auth_backend
 from app.config import settings
 
 router = APIRouter()
@@ -36,7 +36,7 @@ async def google_callback(request: Request, db: AsyncSession = Depends(get_async
         user_to_login = new_user
     
     # Generate JWT token
-    strategy = fastapi_users._auth_backends[0].get_strategy()
+    strategy = auth_backend.get_strategy()
     token_response = await strategy.write_token(user_to_login)
     access_token = token_response["access_token"]
     refresh_token = token_response.get("refresh_token") # Use .get for safety, though it should be there
@@ -86,7 +86,7 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_async_
         user_to_login = new_user
     
     # Generate JWT token
-    strategy = fastapi_users._auth_backends[0].get_strategy()
+    strategy = auth_backend.get_strategy()
     token_response = await strategy.write_token(user_to_login)
     access_token = token_response["access_token"]
     refresh_token = token_response.get("refresh_token") # Use .get for safety
diff --git a/be/app/api/v1/endpoints/groups.py b/be/app/api/v1/endpoints/groups.py
index 0145696..6dcbc99 100644
--- a/be/app/api/v1/endpoints/groups.py
+++ b/be/app/api/v1/endpoints/groups.py
@@ -118,11 +118,57 @@ async def create_group_invite(
     invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id)
     if not invite:
         logger.error(f"Failed to generate unique invite code for group {group_id}")
+        # This case should ideally be covered by exceptions from create_invite now
         raise InviteCreationError(group_id)
 
-    logger.info(f"User {current_user.email} created invite code for group {group_id}")
+    try:
+        await db.commit() # Explicit commit before returning
+        logger.info(f"User {current_user.email} created and committed invite code for group {group_id}")
+    except Exception as e:
+        logger.error(f"Failed to commit transaction after creating invite for group {group_id}: {e}", exc_info=True)
+        await db.rollback() # Ensure rollback if explicit commit fails
+        # Re-raise to ensure a 500 error is returned
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save invite: {str(e)}")
+
     return invite
 
+@router.get(
+    "/{group_id}/invites",
+    response_model=InviteCodePublic, # Or Optional[InviteCodePublic] if it can be null
+    summary="Get Group Active Invite Code",
+    tags=["Groups", "Invites"]
+)
+async def get_group_active_invite(
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: UserModel = Depends(current_active_user),
+):
+    """Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed)."""
+    logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}")
+    
+    # Permission check: Ensure user is a member of the group to view invite code
+    # Using get_user_role_in_group which also checks membership indirectly
+    user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
+    if user_role is None: # Not a member
+        logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.")
+        # More specific error or let GroupPermissionError handle if we want to be generic
+        raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
+
+    # Fetch the active invite for the group
+    invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
+    
+    if not invite:
+        # This case means no active (non-expired, active=true) invite exists.
+        # The frontend can then prompt to generate one.
+        logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}")
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="No active invite code found for this group. Please generate one."
+        )
+    
+    logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}")
+    return invite # Pydantic will convert InviteModel to InviteCodePublic
+
 @router.delete(
     "/{group_id}/leave",
     response_model=Message,
diff --git a/be/app/crud/invite.py b/be/app/crud/invite.py
index 8e5ae09..6ffd2ec 100644
--- a/be/app/crud/invite.py
+++ b/be/app/crud/invite.py
@@ -20,68 +20,114 @@ from app.core.exceptions import (
 # Invite codes should be reasonably unique, but handle potential collision
 MAX_CODE_GENERATION_ATTEMPTS = 5
 
-async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 7) -> Optional[InviteModel]:
-    """Creates a new invite code for a group."""
+async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: int):
+    """Deactivates all currently active invite codes for a specific group."""
+    try:
+        stmt = (
+            select(InviteModel)
+            .where(InviteModel.group_id == group_id, InviteModel.is_active == True)
+        )
+        result = await db.execute(stmt)
+        active_invites = result.scalars().all()
+
+        if not active_invites:
+            return # No active invites to deactivate
+
+        for invite in active_invites:
+            invite.is_active = False
+            db.add(invite)
+        
+        # await db.flush() # Removed: Rely on caller to flush/commit
+        # No explicit commit here, assuming it's part of a larger transaction or caller handles commit.
+    except OperationalError as e:
+        # It's better to let the caller handle rollback or commit based on overall operation success
+        raise DatabaseConnectionError(f"DB connection error deactivating invites for group {group_id}: {str(e)}")
+    except SQLAlchemyError as e:
+        raise DatabaseTransactionError(f"DB transaction error deactivating invites for group {group_id}: {str(e)}")
+
+async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]: # Default to 100 years
+    """Creates a new invite code for a group, deactivating any existing active ones for that group first."""
+    
+    # Deactivate existing active invites for this group
+    await deactivate_all_active_invites_for_group(db, group_id)
+
     expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
     
     potential_code = None
     for attempt in range(MAX_CODE_GENERATION_ATTEMPTS):
         potential_code = secrets.token_urlsafe(16)
-        # Check if an *active* invite with this code already exists (outside main transaction for now)
-        # Ideally, unique constraint on (code, is_active=true) in DB and catch IntegrityError.
-        # This check reduces collision chance before attempting transaction.
         existing_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
         existing_result = await db.execute(existing_check_stmt)
         if existing_result.scalar_one_or_none() is None:
-            break # Found a potentially unique code
+            break
         if attempt == MAX_CODE_GENERATION_ATTEMPTS - 1:
             raise InviteOperationError("Failed to generate a unique invite code after several attempts.")
 
+    # Removed explicit transaction block here, rely on FastAPI's request-level transaction.
+    # Final check for code collision (less critical now without explicit nested transaction rollback on collision)
+    # but still good to prevent duplicate active codes if possible, though the deactivate step helps.
+    final_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
+    final_check_result = await db.execute(final_check_stmt)
+    if final_check_result.scalar_one_or_none() is not None:
+        # This is now more of a rare edge case if deactivate worked and code generation is diverse.
+        # Depending on strictness, could raise an error or just log and proceed, 
+        # relying on the previous deactivation to ensure only one is active.
+        # For now, let's raise to be safe, as it implies a very quick collision.
+        raise InviteOperationError("Invite code collision detected just before creation attempt.")
+
+    db_invite = InviteModel(
+        code=potential_code,
+        group_id=group_id,
+        created_by_id=creator_id,
+        expires_at=expires_at,
+        is_active=True
+    )
+    db.add(db_invite)
+    await db.flush() # Flush to get ID for re-fetch and ensure it's in session before potential re-fetch.
+
+    # Re-fetch with relationships
+    stmt = (
+        select(InviteModel)
+        .where(InviteModel.id == db_invite.id)
+        .options(
+            selectinload(InviteModel.group),
+            selectinload(InviteModel.creator)
+        )
+    )
+    result = await db.execute(stmt)
+    loaded_invite = result.scalar_one_or_none()
+
+    if loaded_invite is None:
+        # This would be an issue, implies flush didn't work or ID was wrong.
+        # The main transaction will rollback if this exception is raised.
+        raise InviteOperationError("Failed to load invite after creation and flush.")
+
+    return loaded_invite
+    # No explicit commit here, FastAPI handles it for the request.
+
+async def get_active_invite_for_group(db: AsyncSession, group_id: int) -> Optional[InviteModel]:
+    """Gets the currently active and non-expired invite for a specific group."""
+    now = datetime.now(timezone.utc)
     try:
-        async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
-            # Final check within transaction to be absolutely sure before insert
-            final_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
-            final_check_result = await db.execute(final_check_stmt)
-            if final_check_result.scalar_one_or_none() is not None:
-                # Extremely unlikely if previous check passed, but handles race condition
-                await transaction.rollback()
-                raise InviteOperationError("Invite code collision detected during transaction.")
-
-            db_invite = InviteModel(
-                code=potential_code,
-                group_id=group_id,
-                created_by_id=creator_id,
-                expires_at=expires_at,
-                is_active=True
+        stmt = (
+            select(InviteModel).where(
+                InviteModel.group_id == group_id,
+                InviteModel.is_active == True,
+                InviteModel.expires_at > now # Still respect expiry, even if very long
             )
-            db.add(db_invite)
-            await db.flush() # Assigns ID
-
-            # Re-fetch with relationships
-            stmt = (
-                select(InviteModel)
-                .where(InviteModel.id == db_invite.id)
-                .options(
-                    selectinload(InviteModel.group),
-                    selectinload(InviteModel.creator)
-                )
+            .order_by(InviteModel.created_at.desc()) # Get the most recent one if multiple (should not happen)
+            .limit(1)
+            .options(
+                selectinload(InviteModel.group), # Eager load group
+                selectinload(InviteModel.creator) # Eager load creator
             )
-            result = await db.execute(stmt)
-            loaded_invite = result.scalar_one_or_none()
-
-            if loaded_invite is None:
-                await transaction.rollback()
-                raise InviteOperationError("Failed to load invite after creation.")
-
-            await transaction.commit()
-            return loaded_invite
-    except IntegrityError as e: # Catch if DB unique constraint on code is violated
-        # Rollback handled by context manager
-        raise DatabaseIntegrityError(f"Failed to create invite due to DB integrity: {str(e)}")
+        )
+        result = await db.execute(stmt)
+        return result.scalars().first()
     except OperationalError as e:
-        raise DatabaseConnectionError(f"DB connection error during invite creation: {str(e)}")
+        raise DatabaseConnectionError(f"DB connection error fetching active invite for group {group_id}: {str(e)}")
     except SQLAlchemyError as e:
-        raise DatabaseTransactionError(f"DB transaction error during invite creation: {str(e)}")
+        raise DatabaseQueryError(f"DB query error fetching active invite for group {group_id}: {str(e)}")
 
 async def get_active_invite_by_code(db: AsyncSession, code: str) -> Optional[InviteModel]:
     """Gets an active and non-expired invite by its code."""
diff --git a/fe/src/config/api-config.ts b/fe/src/config/api-config.ts
index 5b9bb3e..5e998ca 100644
--- a/fe/src/config/api-config.ts
+++ b/fe/src/config/api-config.ts
@@ -51,6 +51,8 @@ export const API_ENDPOINTS = {
         LISTS: (groupId: string) => `/groups/${groupId}/lists`,
         MEMBERS: (groupId: string) => `/groups/${groupId}/members`,
         MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`,
+        CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
+        GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
         LEAVE: (groupId: string) => `/groups/${groupId}/leave`,
         DELETE: (groupId: string) => `/groups/${groupId}`,
         SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue
index a961f8b..227cd76 100644
--- a/fe/src/pages/GroupDetailPage.vue
+++ b/fe/src/pages/GroupDetailPage.vue
@@ -54,21 +54,24 @@
         <div class="card-body">
           <button class="btn btn-secondary" @click="generateInviteCode" :disabled="generatingInvite">
             <span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
-            Generate Invite Code
+            {{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
           </button>
           <div v-if="inviteCode" class="form-group mt-2">
-            <label for="inviteCodeInput" class="form-label">Invite Code:</label>
+            <label for="inviteCodeInput" class="form-label">Current Active Invite Code:</label>
             <div class="flex items-center">
               <input id="inviteCodeInput" type="text" :value="inviteCode" class="form-input flex-grow" readonly />
               <button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler"
                 aria-label="Copy invite code">
                 <svg class="icon">
                   <use xlink:href="#icon-clipboard"></use>
-                </svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
+                </svg>
               </button>
             </div>
             <p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
           </div>
+          <div v-else class="mt-2">
+            <p class="text-muted">No active invite code. Click the button above to generate one.</p>
+          </div>
         </div>
       </div>
 
@@ -112,6 +115,7 @@ const group = ref<Group | null>(null);
 const loading = ref(true);
 const error = ref<string | null>(null);
 const inviteCode = ref<string | null>(null);
+const inviteExpiresAt = ref<string | null>(null);
 const generatingInvite = ref(false);
 const copySuccess = ref(false);
 const removingMember = ref<number | null>(null);
@@ -123,6 +127,33 @@ const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
   source: computed(() => inviteCode.value || '')
 });
 
+const fetchActiveInviteCode = async () => {
+  if (!groupId.value) return;
+  // Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading
+  try {
+    const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value)));
+    if (response.data && response.data.code) {
+      inviteCode.value = response.data.code;
+      inviteExpiresAt.value = response.data.expires_at; // Store expiry
+    } else {
+      inviteCode.value = null; // No active code found
+      inviteExpiresAt.value = null;
+    }
+  } catch (err: any) {
+    if (err.response && err.response.status === 404) {
+      inviteCode.value = null; // Explicitly set to null on 404
+      inviteExpiresAt.value = null;
+      // Optional: notify user or set a flag to show "generate one" message more prominently
+      console.info('No active invite code found for this group.');
+    } else {
+      const message = err instanceof Error ? err.message : 'Failed to fetch active invite code.';
+      // error.value = message; // This would display a large error banner, might be too much
+      console.error('Error fetching active invite code:', err);
+      notificationStore.addNotification({ message, type: 'error' });
+    }
+  }
+};
+
 const fetchGroupDetails = async () => {
   if (!groupId.value) return;
   loading.value = true;
@@ -138,19 +169,24 @@ const fetchGroupDetails = async () => {
   } finally {
     loading.value = false;
   }
+  // Fetch active invite code after group details are loaded
+  await fetchActiveInviteCode();
 };
 
 const generateInviteCode = async () => {
   if (!groupId.value) return;
   generatingInvite.value = true;
-  inviteCode.value = null;
   copySuccess.value = false;
   try {
-    const response = await apiClient.post(API_ENDPOINTS.INVITES.BASE, {
-      group_id: groupId.value, // Ensure this matches API expectation (string or number)
-    });
-    inviteCode.value = response.data.invite_code;
-    notificationStore.addNotification({ message: 'Invite code generated successfully!', type: 'success' });
+    const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value)));
+    if (response.data && response.data.code) {
+      inviteCode.value = response.data.code;
+      inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
+      notificationStore.addNotification({ message: 'New invite code generated successfully!', type: 'success' });
+    } else {
+      // Should not happen if POST is successful and returns the code
+      throw new Error('New invite code data is invalid.');
+    }
   } catch (err: unknown) {
     const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
     console.error('Error generating invite code:', err);