diff --git a/server/app/domains/chat/api/history_controller.py b/server/app/domains/chat/api/history_controller.py index 436917529..70141217c 100644 --- a/server/app/domains/chat/api/history_controller.py +++ b/server/app/domains/chat/api/history_controller.py @@ -22,16 +22,15 @@ 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 sqlmodel import Session, case, desc, func, select from fastapi_babel import _ from app.core.database import session 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.project import Project 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.user.key import Key from app.shared.auth import auth_must from app.shared.auth.user_auth import V1UserAuth @@ -171,11 +170,24 @@ def delete_chat_history(history_id: int, db_session: Session = Depends(session), 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 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( + 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.user_id == auth.id, ChatHistory.project_id == project_id if history.project_id else ChatHistory.task_id == project_id, ) ).first() @@ -185,13 +197,15 @@ def delete_chat_history(history_id: int, db_session: Session = Depends(session), 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) - logger.info( - "Deleted triggers for removed project", extra={"project_id": project_id, "trigger_count": len(triggers)} + SpaceDeletionService.delete_project( + project_id, + auth.id, + db_session, + delete_histories=False, + missing_ok=True, + commit=False, ) + logger.info("Removed durable Project after deleting its final history", extra={"project_id": project_id}) db_session.commit() return Response(status_code=204) @@ -252,4 +266,4 @@ def update_project_name( 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") + raise HTTPException(status_code=500, detail="Internal server error") \ No newline at end of file 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, 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() 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