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 @@
- +

Invite code copied to clipboard!

+
+

No active invite code. Click the button above to generate one.

+
@@ -112,6 +115,7 @@ const group = ref(null); const loading = ref(true); const error = ref(null); const inviteCode = ref(null); +const inviteExpiresAt = ref(null); const generatingInvite = ref(false); const copySuccess = ref(false); const removingMember = ref(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);