diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index a91b6e9..b864f3d 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -18,7 +18,8 @@ from app.core.exceptions import ( ListNotFoundError, ListPermissionError, ListStatusNotFoundError, - ConflictError # Added ConflictError + ConflictError, # Added ConflictError + DatabaseIntegrityError # Added DatabaseIntegrityError ) logger = logging.getLogger(__name__) @@ -29,7 +30,13 @@ router = APIRouter() response_model=ListPublic, # Return basic list info on creation status_code=status.HTTP_201_CREATED, summary="Create New List", - tags=["Lists"] + tags=["Lists"], + responses={ + status.HTTP_409_CONFLICT: { + "description": "Conflict: A list with this name already exists in the specified group", + "model": ListPublic + } + } ) async def create_list( list_in: ListCreate, @@ -40,6 +47,7 @@ async def create_list( Creates a new shopping list. - If `group_id` is provided, the user must be a member of that group. - If `group_id` is null, it's a personal list. + - If a list with the same name already exists in the group, returns 409 with the existing list. """ logger.info(f"User {current_user.email} creating list: {list_in.name}") group_id = list_in.group_id @@ -51,9 +59,29 @@ async def create_list( logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.") raise GroupMembershipError(group_id, "create lists") - created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id) - logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.") - return created_list + try: + created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id) + logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.") + return created_list + except DatabaseIntegrityError as e: + # Check if this is a unique constraint violation + if "unique constraint" in str(e).lower(): + # Find the existing list with the same name in the group + existing_list = await crud_list.get_list_by_name_and_group( + db=db, + name=list_in.name, + group_id=group_id, + user_id=current_user.id + ) + if existing_list: + logger.info(f"List '{list_in.name}' already exists in group {group_id}. Returning existing list.") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"A list named '{list_in.name}' already exists in this group.", + headers={"X-Existing-List": str(existing_list.id)} + ) + # If it's not a unique constraint or we couldn't find the existing list, re-raise + raise @router.get( diff --git a/be/app/crud/list.py b/be/app/crud/list.py index 3921cae..35527c8 100644 --- a/be/app/crud/list.py +++ b/be/app/crud/list.py @@ -206,4 +206,46 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus: except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") except SQLAlchemyError as e: - raise DatabaseQueryError(f"Failed to get list status: {str(e)}") \ No newline at end of file + raise DatabaseQueryError(f"Failed to get list status: {str(e)}") + +async def get_list_by_name_and_group( + db: AsyncSession, + name: str, + group_id: Optional[int], + user_id: int +) -> Optional[ListModel]: + """ + Gets a list by name and group, ensuring the user has permission to access it. + Used for conflict resolution when creating lists. + """ + try: + # Build the base query + query = select(ListModel).where(ListModel.name == name) + + # Add group condition + if group_id is not None: + query = query.where(ListModel.group_id == group_id) + else: + query = query.where(ListModel.group_id.is_(None)) + + # Add permission conditions + conditions = [ + ListModel.created_by_id == user_id # User is creator + ] + if group_id is not None: + # User is member of the group + conditions.append( + and_( + ListModel.group_id == group_id, + ListModel.created_by_id != user_id # Not the creator + ) + ) + + query = query.where(or_(*conditions)) + + result = await db.execute(query) + return result.scalars().first() + except OperationalError as e: + raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") + except SQLAlchemyError as e: + raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}") \ No newline at end of file diff --git a/fe/.gitignore b/fe/.gitignore index aef72d0..c77e501 100644 --- a/fe/.gitignore +++ b/fe/.gitignore @@ -7,7 +7,7 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -node_modules +**/node_modules/ .DS_Store dist dist-ssr @@ -28,6 +28,7 @@ coverage *.sw? *.tsbuildinfo +*.sw.js test-results/ playwright-report/ diff --git a/fe/package-lock.json b/fe/package-lock.json index cd6b435..6fea4b0 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -17,7 +17,8 @@ "pinia": "^3.0.2", "vue": "^3.5.13", "vue-i18n": "^12.0.0-alpha.2", - "vue-router": "^4.5.1" + "vue-router": "^4.5.1", + "workbox-background-sync": "^7.3.0" }, "devDependencies": { "@intlify/unplugin-vue-i18n": "^6.0.8", @@ -7585,7 +7586,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true, "license": "ISC" }, "node_modules/ignore": { @@ -12067,7 +12067,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz", "integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==", - "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -12402,7 +12401,6 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz", "integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==", - "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { diff --git a/fe/package.json b/fe/package.json index ae02d36..82e96f0 100644 --- a/fe/package.json +++ b/fe/package.json @@ -26,7 +26,8 @@ "pinia": "^3.0.2", "vue": "^3.5.13", "vue-i18n": "^12.0.0-alpha.2", - "vue-router": "^4.5.1" + "vue-router": "^4.5.1", + "workbox-background-sync": "^7.3.0" }, "devDependencies": { "@intlify/unplugin-vue-i18n": "^6.0.8", diff --git a/fe/public/offline.html b/fe/public/offline.html new file mode 100644 index 0000000..364bc7b --- /dev/null +++ b/fe/public/offline.html @@ -0,0 +1,45 @@ + + + +
+ + +It seems you've lost your internet connection.
+Please check your network settings and try again once you're back online.
+Some previously cached content might still be available.
+No pending changes.
+No pending changes.