From 483c304fdbe5bdc2a41cc285bcbffb154332f133 Mon Sep 17 00:00:00 2001 From: Cloudy <52116030+xdCloudy@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:00:13 +0100 Subject: [PATCH 1/5] fix: add destructive Space and Project deletion service --- .../domains/space/service/deletion_service.py | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 server/app/domains/space/service/deletion_service.py diff --git a/server/app/domains/space/service/deletion_service.py b/server/app/domains/space/service/deletion_service.py new file mode 100644 index 000000000..d32cf9646 --- /dev/null +++ b/server/app/domains/space/service/deletion_service.py @@ -0,0 +1,188 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Destructive deletion helpers for Spaces and Projects. + +The normal Space/Project service intentionally supports archival, but the UI's +Delete actions promise permanent removal. These helpers implement that +contract in one transaction and clean the dependent rows that otherwise keep +Project/Space records alive. +""" + +from sqlalchemy import and_, or_ +from sqlmodel import Session, delete, select + +from app.domains.space.service.space_service import SpaceService +from app.model.chat.chat_history import ChatHistory +from app.model.memory import ProjectMemory, SpaceMemory +from app.model.project import Project +from app.model.space import SpaceFileIndex, SpaceFileIndexOverlay +from app.model.trigger.trigger import Trigger +from app.model.trigger.trigger_execution import TriggerExecution + + +class SpaceDeletionService: + """Hard-delete Projects and Spaces together with their dependent rows.""" + + @staticmethod + def _history_user_id(user_id: int | str) -> int | str: + # ChatHistory.user_id is still an integer for the normal auth path, + # while Space/Project ownership is stored canonically as a string. + return int(user_id) if str(user_id).isdigit() else user_id + + @staticmethod + def _delete_triggers( + *, + user_id: str, + db_session: Session, + project_id: str | None = None, + space_id: str | None = None, + ) -> None: + stmt = select(Trigger).where(Trigger.user_id == user_id) + if project_id is not None: + stmt = stmt.where(Trigger.project_id == project_id) + if space_id is not None: + stmt = stmt.where(Trigger.space_id == space_id) + + triggers = db_session.exec(stmt).all() + for trigger in triggers: + db_session.exec( + delete(TriggerExecution).where( + TriggerExecution.trigger_id == trigger.id + ) + ) + db_session.delete(trigger) + + @staticmethod + def delete_project( + project_id: str, + user_id: int | str, + db_session: Session, + *, + space_id: str | None = None, + delete_histories: bool = True, + missing_ok: bool = False, + commit: bool = True, + ) -> bool: + """Permanently delete one owned Project and its server-side data. + + Returns ``True`` when a Project row existed. ``missing_ok`` is used by + legacy history cleanup, where old history rows may pre-date the durable + Project table but their triggers/overlays should still be removed. + """ + + canonical_user_id = SpaceService.canonical_user_id(user_id) + history_user_id = SpaceDeletionService._history_user_id(user_id) + + stmt = select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + if space_id is not None: + stmt = stmt.where(Project.space_id == space_id) + project = db_session.exec(stmt).first() + + if project is None and not missing_ok: + raise ValueError("Project not found") + + if delete_histories: + db_session.exec( + delete(ChatHistory).where( + ChatHistory.user_id == history_user_id, + or_( + ChatHistory.project_id == project_id, + and_( + ChatHistory.project_id.is_(None), + ChatHistory.task_id == project_id, + ), + ), + ) + ) + + SpaceDeletionService._delete_triggers( + user_id=canonical_user_id, + project_id=project_id, + db_session=db_session, + ) + db_session.exec( + delete(ProjectMemory).where(ProjectMemory.project_id == project_id) + ) + db_session.exec( + delete(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.project_id == project_id + ) + ) + + if project is not None: + db_session.delete(project) + + if commit: + db_session.commit() + return project is not None + + @staticmethod + def delete_space( + space_id: str, + user_id: int | str, + db_session: Session, + ) -> None: + """Permanently delete an owned Space and every Project it contains.""" + + canonical_user_id = SpaceService.canonical_user_id(user_id) + history_user_id = SpaceDeletionService._history_user_id(user_id) + space = SpaceService.get_space(space_id, canonical_user_id, db_session) + + projects = db_session.exec( + select(Project).where( + Project.user_id == canonical_user_id, + Project.space_id == space_id, + ) + ).all() + for project in projects: + SpaceDeletionService.delete_project( + project.id, + user_id, + db_session, + space_id=space_id, + commit=False, + ) + + # Clean legacy/orphan rows that are keyed directly by Space and may + # have been created before the durable Project model was introduced. + db_session.exec( + delete(ChatHistory).where( + ChatHistory.user_id == history_user_id, + ChatHistory.space_id == space_id, + ) + ) + SpaceDeletionService._delete_triggers( + user_id=canonical_user_id, + space_id=space_id, + db_session=db_session, + ) + db_session.exec( + delete(ProjectMemory).where(ProjectMemory.space_id == space_id) + ) + db_session.exec(delete(SpaceMemory).where(SpaceMemory.space_id == space_id)) + db_session.exec( + delete(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.space_id == space_id + ) + ) + db_session.exec( + delete(SpaceFileIndex).where(SpaceFileIndex.space_id == space_id) + ) + + db_session.delete(space) + db_session.commit() From b8f54740151edca5f2b4de4aefaa886fda22f8e3 Mon Sep 17 00:00:00 2001 From: Cloudy <52116030+xdCloudy@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:00:48 +0100 Subject: [PATCH 2/5] fix: cascade Space deletion and add Project delete endpoint --- .../app/domains/space/api/space_controller.py | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/server/app/domains/space/api/space_controller.py b/server/app/domains/space/api/space_controller.py index 99e644d0d..05f95587c 100644 --- a/server/app/domains/space/api/space_controller.py +++ b/server/app/domains/space/api/space_controller.py @@ -18,11 +18,12 @@ from app.core.database import session from app.domains.space.service.apply_service import SpaceApplyService +from app.domains.space.service.deletion_service import SpaceDeletionService from app.domains.space.service.overlay_service import ( PendingOverlayError, SpaceOverlayService, ) -from app.domains.space.service.space_service import SpaceHasProjectsError, SpaceService +from app.domains.space.service.space_service import SpaceService from app.model.project import ProjectIn, ProjectOut, ProjectUpdate from app.model.space import ( SpaceIn, @@ -99,17 +100,7 @@ def delete_space( auth: V1UserAuth = Depends(auth_must), ): try: - SpaceService.delete_space(space_id, auth.id, db_session) - except SpaceHasProjectsError as exc: - raise HTTPException( - status_code=409, - detail={ - "code": "space_has_projects", - "message": str(exc), - "project_count": exc.project_count, - "projects": exc.projects, - }, - ) from exc + SpaceDeletionService.delete_space(space_id, auth.id, db_session) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -214,6 +205,28 @@ def update_space_project( raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.delete( + "/{space_id}/projects/{project_id}", + name="delete space project", + status_code=204, +) +def delete_space_project( + space_id: str, + project_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + SpaceDeletionService.delete_project( + project_id, + auth.id, + db_session, + space_id=space_id, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.post("/{space_id}/projects/{project_id}/promote", name="promote project to folder space", response_model=ProjectOut) def promote_space_project( space_id: str, From 3003b26226cb35f0e4147e7c2d6d0889e0ed442c Mon Sep 17 00:00:00 2001 From: Cloudy <52116030+xdCloudy@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:01:23 +0100 Subject: [PATCH 3/5] fix: remove durable Project after final history deletion --- .../domains/chat/api/history_controller.py | 153 +++++++++++++----- 1 file changed, 115 insertions(+), 38 deletions(-) diff --git a/server/app/domains/chat/api/history_controller.py b/server/app/domains/chat/api/history_controller.py index 436917529..0b3f1b550 100644 --- a/server/app/domains/chat/api/history_controller.py +++ b/server/app/domains/chat/api/history_controller.py @@ -18,24 +18,29 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi_babel import _ from fastapi_pagination import Page from fastapi_pagination.ext.sqlmodel import paginate from loguru import logger from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, case, delete, desc, func, select -from fastapi_babel import _ +from sqlmodel import Session, case, desc, func, select from app.core.database import session +from app.domains.chat.service.chat_service import ChatService from app.domains.space.service import SpaceService -from app.model.chat.chat_history import ChatHistory, ChatHistoryIn, ChatHistoryOut, ChatHistoryUpdate, ChatStatus -from app.model.project import Project +from app.domains.space.service.deletion_service import SpaceDeletionService +from app.model.chat.chat_history import ( + ChatHistory, + ChatHistoryIn, + ChatHistoryOut, + ChatHistoryUpdate, + ChatStatus, +) from app.model.chat.chat_history_grouped import GroupedHistoryResponse, ProjectGroup -from app.model.trigger.trigger import Trigger -from app.model.trigger.trigger_execution import TriggerExecution +from app.model.project import Project from app.model.user.key import Key from app.shared.auth import auth_must from app.shared.auth.user_auth import V1UserAuth -from app.domains.chat.service.chat_service import ChatService router = APIRouter(prefix="/chat", tags=["Chat History"]) @@ -92,7 +97,11 @@ def _drop_stale_ongoing_status(history: ChatHistory, update_data: dict) -> None: @router.post("/history", name="save chat history", response_model=ChatHistoryOut) -def create_chat_history(data: ChatHistoryIn, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must)): +def create_chat_history( + data: ChatHistoryIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): data.user_id = auth.id data.project_id = data.project_id or data.task_id data.run_id = data.run_id or data.task_id @@ -120,13 +129,18 @@ def create_chat_history(data: ChatHistoryIn, db_session: Session = Depends(sessi db_session.commit() except IntegrityError as exc: db_session.rollback() - raise HTTPException(status_code=409, detail="Chat history already exists") from exc + raise HTTPException( + status_code=409, detail="Chat history already exists" + ) from exc db_session.refresh(chat_history) return chat_history @router.get("/histories", name="get chat history") -def list_chat_history(db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must)) -> Page[ChatHistoryOut]: +def list_chat_history( + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +) -> Page[ChatHistoryOut]: stmt = ( select(ChatHistory) .where(ChatHistory.user_id == auth.id) @@ -141,71 +155,118 @@ def list_chat_history(db_session: Session = Depends(session), auth: V1UserAuth = @router.get("/histories/grouped", name="get grouped chat history") def list_grouped_chat_history( - include_tasks: Optional[bool] = Query(True, description="Whether to include individual tasks in groups"), + include_tasks: Optional[bool] = Query( + True, description="Whether to include individual tasks in groups" + ), space_id: Optional[str] = Query(None, description="Optional Space ID filter"), db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must), ) -> GroupedHistoryResponse: - return ChatService.get_grouped_histories(auth.id, include_tasks, db_session, space_id) + return ChatService.get_grouped_histories( + auth.id, include_tasks, db_session, space_id + ) @router.get("/histories/grouped/{project_id}", name="get single grouped project") def get_grouped_project( project_id: str, - include_tasks: Optional[bool] = Query(True, description="Whether to include individual tasks in the project"), + include_tasks: Optional[bool] = Query( + True, description="Whether to include individual tasks in the project" + ), db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must), ) -> ProjectGroup: - result = ChatService.get_grouped_project(auth.id, project_id, include_tasks, db_session) + result = ChatService.get_grouped_project( + auth.id, project_id, include_tasks, db_session + ) if result is None: raise HTTPException(status_code=404, detail="Project not found") return result @router.delete("/history/{history_id}", name="delete chat history") -def delete_chat_history(history_id: int, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must)): - history = db_session.exec(select(ChatHistory).where(ChatHistory.id == history_id)).first() +def delete_chat_history( + history_id: int, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + history = db_session.exec( + select(ChatHistory).where(ChatHistory.id == history_id) + ).first() if not history: raise HTTPException(status_code=404, detail="Chat History not found") if history.user_id != auth.id: - raise HTTPException(status_code=403, detail="You are not allowed to delete this chat history") + raise HTTPException( + status_code=403, + detail="You are not allowed to delete this chat history", + ) project_id = history.project_id if history.project_id else history.task_id + canonical_user_id = SpaceService.canonical_user_id(auth.id) + + # Project deletion in the desktop UI fans history deletes out in parallel. + # Serialize deletes for the same Project so one request reliably observes + # itself as the final history row and performs durable Project cleanup. + db_session.exec( + select(Project) + .where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + .with_for_update() + ).first() - sibling_count = ( - db_session.exec( - select(func.count(ChatHistory.id)).where( - ChatHistory.id != history_id, - ChatHistory.project_id == project_id if history.project_id else ChatHistory.task_id == project_id, - ) - ).first() - or 0 + sibling_stmt = select(func.count(ChatHistory.id)).where( + ChatHistory.id != history_id, + ChatHistory.user_id == auth.id, ) + if history.project_id: + sibling_stmt = sibling_stmt.where(ChatHistory.project_id == project_id) + else: + sibling_stmt = sibling_stmt.where(ChatHistory.task_id == project_id) + sibling_count = db_session.exec(sibling_stmt).first() or 0 db_session.delete(history) if sibling_count == 0: - triggers = db_session.exec(select(Trigger).where(Trigger.project_id == project_id)).all() - for trigger in triggers: - db_session.exec(delete(TriggerExecution).where(TriggerExecution.trigger_id == trigger.id)) - db_session.delete(trigger) + SpaceDeletionService.delete_project( + project_id, + auth.id, + db_session, + delete_histories=False, + missing_ok=True, + commit=False, + ) logger.info( - "Deleted triggers for removed project", extra={"project_id": project_id, "trigger_count": len(triggers)} + "Removed durable Project after deleting its final history", + extra={"project_id": project_id}, ) db_session.commit() return Response(status_code=204) -@router.put("/history/{history_id}", name="update chat history", response_model=ChatHistoryOut) +@router.put( + "/history/{history_id}", + name="update chat history", + response_model=ChatHistoryOut, +) async def update_chat_history( - history_id: int, data: ChatHistoryUpdate, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must) + history_id: int, + data: ChatHistoryUpdate, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), ): - history = db_session.exec(select(ChatHistory).where(ChatHistory.id == history_id)).first() + history = db_session.exec( + select(ChatHistory).where(ChatHistory.id == history_id) + ).first() if not history: raise HTTPException(status_code=404, detail="Chat History not found") if history.user_id != auth.id: - raise HTTPException(status_code=403, detail="You are not allowed to update this chat history") + raise HTTPException( + status_code=403, + detail="You are not allowed to update this chat history", + ) update_data = data.model_dump(exclude_unset=True) # Chat history text fields are length-bounded; clamp defensively so an @@ -228,14 +289,23 @@ async def update_chat_history( @router.put("/project/{project_id}/name", name="update project name") def update_project_name( - project_id: str, new_name: str, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must) + project_id: str, + new_name: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), ): user_id = auth.id - stmt = select(ChatHistory).where(ChatHistory.project_id == project_id).where(ChatHistory.user_id == user_id) + stmt = ( + select(ChatHistory) + .where(ChatHistory.project_id == project_id) + .where(ChatHistory.user_id == user_id) + ) histories = db_session.exec(stmt).all() if not histories: - raise HTTPException(status_code=404, detail="Project not found or access denied") + raise HTTPException( + status_code=404, detail="Project not found or access denied" + ) try: for history in histories: @@ -251,5 +321,12 @@ def update_project_name( return Response(status_code=200) except Exception as e: db_session.rollback() - logger.error("Project name update failed", extra={"user_id": user_id, "project_id": project_id, "error": str(e)}) + logger.error( + "Project name update failed", + extra={ + "user_id": user_id, + "project_id": project_id, + "error": str(e), + }, + ) raise HTTPException(status_code=500, detail="Internal server error") From a7445932e1ed6a2d88efd71876b93ca7dd537832 Mon Sep 17 00:00:00 2001 From: Cloudy <52116030+xdCloudy@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:01:56 +0100 Subject: [PATCH 4/5] test: cover destructive Project and Space deletion --- server/tests/test_space_project_deletion.py | 195 ++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 server/tests/test_space_project_deletion.py diff --git a/server/tests/test_space_project_deletion.py b/server/tests/test_space_project_deletion.py new file mode 100644 index 000000000..962e5ddba --- /dev/null +++ b/server/tests/test_space_project_deletion.py @@ -0,0 +1,195 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from __future__ import annotations + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from app.domains.space.service.deletion_service import SpaceDeletionService +from app.model.chat.chat_history import ChatHistory +from app.model.memory import ProjectMemory, SpaceMemory +from app.model.project import Project +from app.model.space import ( + Space, + SpaceFileIndex, + SpaceFileIndexOverlay, + SpaceSourceType, +) +from app.model.trigger.trigger import Trigger +from app.model.trigger.trigger_execution import TriggerExecution + + +@pytest.fixture +def db_session(): + engine = create_engine("sqlite://") + SQLModel.metadata.create_all( + engine, + tables=[ + Space.__table__, + Project.__table__, + ChatHistory.__table__, + SpaceMemory.__table__, + ProjectMemory.__table__, + SpaceFileIndex.__table__, + SpaceFileIndexOverlay.__table__, + Trigger.__table__, + TriggerExecution.__table__, + ], + ) + with Session(engine) as session: + yield session + + +def _seed_space_project(db_session: Session, suffix: str = "one") -> tuple[str, str]: + space_id = f"space-{suffix}" + project_id = f"project-{suffix}" + task_id = f"task-{suffix}" + + db_session.add( + Space( + id=space_id, + user_id="1", + name=f"Space {suffix}", + source_type=SpaceSourceType.BLANK, + ) + ) + db_session.add( + Project( + id=project_id, + user_id="1", + space_id=space_id, + name=f"Project {suffix}", + ) + ) + db_session.add( + ChatHistory( + user_id=1, + task_id=task_id, + project_id=project_id, + space_id=space_id, + run_id=task_id, + question="test", + language="en", + model_platform="local", + model_type="test", + api_key="", + api_url="http://localhost", + ) + ) + db_session.add( + SpaceMemory( + user_id="1", + space_id=space_id, + key=f"space-memory-{suffix}", + ) + ) + db_session.add( + ProjectMemory( + user_id="1", + space_id=space_id, + project_id=project_id, + key=f"project-memory-{suffix}", + ) + ) + db_session.add( + SpaceFileIndex( + space_id=space_id, + path=f"src/{suffix}.rs", + ) + ) + db_session.add( + SpaceFileIndexOverlay( + space_id=space_id, + project_id=project_id, + run_id=task_id, + path=f"src/{suffix}.rs", + status="modified", + ) + ) + db_session.commit() + return space_id, project_id + + +def test_delete_project_removes_durable_project_data_but_keeps_space(db_session): + space_id, project_id = _seed_space_project(db_session) + + SpaceDeletionService.delete_project( + project_id, + 1, + db_session, + space_id=space_id, + ) + + assert db_session.get(Project, project_id) is None + assert db_session.exec( + select(ChatHistory).where(ChatHistory.project_id == project_id) + ).first() is None + assert db_session.exec( + select(ProjectMemory).where(ProjectMemory.project_id == project_id) + ).first() is None + assert db_session.exec( + select(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.project_id == project_id + ) + ).first() is None + + # Project deletion must not remove Space-scoped state. + assert db_session.get(Space, space_id) is not None + assert db_session.exec( + select(SpaceMemory).where(SpaceMemory.space_id == space_id) + ).first() is not None + assert db_session.exec( + select(SpaceFileIndex).where(SpaceFileIndex.space_id == space_id) + ).first() is not None + + +def test_delete_space_cascades_projects_and_space_scoped_data(db_session): + space_id, project_id = _seed_space_project(db_session, "cascade") + + SpaceDeletionService.delete_space(space_id, 1, db_session) + + assert db_session.get(Space, space_id) is None + assert db_session.get(Project, project_id) is None + assert db_session.exec( + select(ChatHistory).where(ChatHistory.space_id == space_id) + ).first() is None + assert db_session.exec( + select(ProjectMemory).where(ProjectMemory.space_id == space_id) + ).first() is None + assert db_session.exec( + select(SpaceMemory).where(SpaceMemory.space_id == space_id) + ).first() is None + assert db_session.exec( + select(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.space_id == space_id + ) + ).first() is None + assert db_session.exec( + select(SpaceFileIndex).where(SpaceFileIndex.space_id == space_id) + ).first() is None + + +def test_delete_project_rejects_project_owned_by_another_user(db_session): + space_id, project_id = _seed_space_project(db_session, "ownership") + + with pytest.raises(ValueError, match="Project not found"): + SpaceDeletionService.delete_project( + project_id, + 2, + db_session, + space_id=space_id, + ) + + assert db_session.get(Project, project_id) is not None From d7df61af6dfe1812e0d645ef352d636e2217f6cd Mon Sep 17 00:00:00 2001 From: Cloudy <52116030+xdCloudy@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:04:22 +0100 Subject: [PATCH 5/5] refactor: keep history deletion patch focused --- .../domains/chat/api/history_controller.py | 133 +++++------------- 1 file changed, 35 insertions(+), 98 deletions(-) diff --git a/server/app/domains/chat/api/history_controller.py b/server/app/domains/chat/api/history_controller.py index 0b3f1b550..70141217c 100644 --- a/server/app/domains/chat/api/history_controller.py +++ b/server/app/domains/chat/api/history_controller.py @@ -18,29 +18,23 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Response -from fastapi_babel import _ from fastapi_pagination import Page from fastapi_pagination.ext.sqlmodel import paginate from loguru import logger from sqlalchemy.exc import IntegrityError from sqlmodel import Session, case, desc, func, select +from fastapi_babel import _ from app.core.database import session -from app.domains.chat.service.chat_service import ChatService from app.domains.space.service import SpaceService from app.domains.space.service.deletion_service import SpaceDeletionService -from app.model.chat.chat_history import ( - ChatHistory, - ChatHistoryIn, - ChatHistoryOut, - ChatHistoryUpdate, - ChatStatus, -) -from app.model.chat.chat_history_grouped import GroupedHistoryResponse, ProjectGroup +from app.model.chat.chat_history import ChatHistory, ChatHistoryIn, ChatHistoryOut, ChatHistoryUpdate, ChatStatus from app.model.project import Project +from app.model.chat.chat_history_grouped import GroupedHistoryResponse, ProjectGroup from app.model.user.key import Key from app.shared.auth import auth_must from app.shared.auth.user_auth import V1UserAuth +from app.domains.chat.service.chat_service import ChatService router = APIRouter(prefix="/chat", tags=["Chat History"]) @@ -97,11 +91,7 @@ def _drop_stale_ongoing_status(history: ChatHistory, update_data: dict) -> None: @router.post("/history", name="save chat history", response_model=ChatHistoryOut) -def create_chat_history( - data: ChatHistoryIn, - db_session: Session = Depends(session), - auth: V1UserAuth = Depends(auth_must), -): +def create_chat_history(data: ChatHistoryIn, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must)): data.user_id = auth.id data.project_id = data.project_id or data.task_id data.run_id = data.run_id or data.task_id @@ -129,18 +119,13 @@ def create_chat_history( db_session.commit() except IntegrityError as exc: db_session.rollback() - raise HTTPException( - status_code=409, detail="Chat history already exists" - ) from exc + raise HTTPException(status_code=409, detail="Chat history already exists") from exc db_session.refresh(chat_history) return chat_history @router.get("/histories", name="get chat history") -def list_chat_history( - db_session: Session = Depends(session), - auth: V1UserAuth = Depends(auth_must), -) -> Page[ChatHistoryOut]: +def list_chat_history(db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must)) -> Page[ChatHistoryOut]: stmt = ( select(ChatHistory) .where(ChatHistory.user_id == auth.id) @@ -155,58 +140,40 @@ def list_chat_history( @router.get("/histories/grouped", name="get grouped chat history") def list_grouped_chat_history( - include_tasks: Optional[bool] = Query( - True, description="Whether to include individual tasks in groups" - ), + include_tasks: Optional[bool] = Query(True, description="Whether to include individual tasks in groups"), space_id: Optional[str] = Query(None, description="Optional Space ID filter"), db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must), ) -> GroupedHistoryResponse: - return ChatService.get_grouped_histories( - auth.id, include_tasks, db_session, space_id - ) + return ChatService.get_grouped_histories(auth.id, include_tasks, db_session, space_id) @router.get("/histories/grouped/{project_id}", name="get single grouped project") def get_grouped_project( project_id: str, - include_tasks: Optional[bool] = Query( - True, description="Whether to include individual tasks in the project" - ), + include_tasks: Optional[bool] = Query(True, description="Whether to include individual tasks in the project"), db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must), ) -> ProjectGroup: - result = ChatService.get_grouped_project( - auth.id, project_id, include_tasks, db_session - ) + result = ChatService.get_grouped_project(auth.id, project_id, include_tasks, db_session) if result is None: raise HTTPException(status_code=404, detail="Project not found") return result @router.delete("/history/{history_id}", name="delete chat history") -def delete_chat_history( - history_id: int, - db_session: Session = Depends(session), - auth: V1UserAuth = Depends(auth_must), -): - history = db_session.exec( - select(ChatHistory).where(ChatHistory.id == history_id) - ).first() +def delete_chat_history(history_id: int, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must)): + history = db_session.exec(select(ChatHistory).where(ChatHistory.id == history_id)).first() if not history: raise HTTPException(status_code=404, detail="Chat History not found") if history.user_id != auth.id: - raise HTTPException( - status_code=403, - detail="You are not allowed to delete this chat history", - ) + raise HTTPException(status_code=403, detail="You are not allowed to delete this chat history") project_id = history.project_id if history.project_id else history.task_id canonical_user_id = SpaceService.canonical_user_id(auth.id) - # Project deletion in the desktop UI fans history deletes out in parallel. - # Serialize deletes for the same Project so one request reliably observes - # itself as the final history row and performs durable Project cleanup. + # Project deletion fans history deletes out in parallel. Serialize them on + # the durable Project row so exactly one request observes the final history. db_session.exec( select(Project) .where( @@ -216,15 +183,16 @@ def delete_chat_history( .with_for_update() ).first() - sibling_stmt = select(func.count(ChatHistory.id)).where( - ChatHistory.id != history_id, - ChatHistory.user_id == auth.id, + sibling_count = ( + db_session.exec( + select(func.count(ChatHistory.id)).where( + ChatHistory.id != history_id, + ChatHistory.user_id == auth.id, + ChatHistory.project_id == project_id if history.project_id else ChatHistory.task_id == project_id, + ) + ).first() + or 0 ) - if history.project_id: - sibling_stmt = sibling_stmt.where(ChatHistory.project_id == project_id) - else: - sibling_stmt = sibling_stmt.where(ChatHistory.task_id == project_id) - sibling_count = db_session.exec(sibling_stmt).first() or 0 db_session.delete(history) @@ -237,36 +205,21 @@ def delete_chat_history( missing_ok=True, commit=False, ) - logger.info( - "Removed durable Project after deleting its final history", - extra={"project_id": project_id}, - ) + logger.info("Removed durable Project after deleting its final history", extra={"project_id": project_id}) db_session.commit() return Response(status_code=204) -@router.put( - "/history/{history_id}", - name="update chat history", - response_model=ChatHistoryOut, -) +@router.put("/history/{history_id}", name="update chat history", response_model=ChatHistoryOut) async def update_chat_history( - history_id: int, - data: ChatHistoryUpdate, - db_session: Session = Depends(session), - auth: V1UserAuth = Depends(auth_must), + history_id: int, data: ChatHistoryUpdate, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must) ): - history = db_session.exec( - select(ChatHistory).where(ChatHistory.id == history_id) - ).first() + history = db_session.exec(select(ChatHistory).where(ChatHistory.id == history_id)).first() if not history: raise HTTPException(status_code=404, detail="Chat History not found") if history.user_id != auth.id: - raise HTTPException( - status_code=403, - detail="You are not allowed to update this chat history", - ) + raise HTTPException(status_code=403, detail="You are not allowed to update this chat history") update_data = data.model_dump(exclude_unset=True) # Chat history text fields are length-bounded; clamp defensively so an @@ -289,23 +242,14 @@ async def update_chat_history( @router.put("/project/{project_id}/name", name="update project name") def update_project_name( - project_id: str, - new_name: str, - db_session: Session = Depends(session), - auth: V1UserAuth = Depends(auth_must), + project_id: str, new_name: str, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must) ): user_id = auth.id - stmt = ( - select(ChatHistory) - .where(ChatHistory.project_id == project_id) - .where(ChatHistory.user_id == user_id) - ) + stmt = select(ChatHistory).where(ChatHistory.project_id == project_id).where(ChatHistory.user_id == user_id) histories = db_session.exec(stmt).all() if not histories: - raise HTTPException( - status_code=404, detail="Project not found or access denied" - ) + raise HTTPException(status_code=404, detail="Project not found or access denied") try: for history in histories: @@ -321,12 +265,5 @@ def update_project_name( return Response(status_code=200) except Exception as e: db_session.rollback() - logger.error( - "Project name update failed", - extra={ - "user_id": user_id, - "project_id": project_id, - "error": str(e), - }, - ) - raise HTTPException(status_code=500, detail="Internal server error") + logger.error("Project name update failed", extra={"user_id": user_id, "project_id": project_id, "error": str(e)}) + raise HTTPException(status_code=500, detail="Internal server error") \ No newline at end of file