diff --git a/backend/api/user_agents.py b/backend/api/user_agents.py index c81d463..0365a49 100644 --- a/backend/api/user_agents.py +++ b/backend/api/user_agents.py @@ -106,10 +106,16 @@ async def create_agent_instance( ): """Create a new instance of a user agent (trigger webhook if applicable)""" - # Get the user agent + # Get the user agent (excluding soft-deleted ones) user_agent = ( db.query(UserAgent) - .filter(and_(UserAgent.id == agent_id, UserAgent.user_id == current_user.id)) + .filter( + and_( + UserAgent.id == agent_id, + UserAgent.user_id == current_user.id, + UserAgent.is_deleted.is_(False), + ) + ) .first() ) diff --git a/backend/db/queries.py b/backend/db/queries.py index 3a5d42e..b50b1ac 100644 --- a/backend/db/queries.py +++ b/backend/db/queries.py @@ -61,11 +61,11 @@ def _format_instance(instance: AgentInstance) -> AgentInstanceResponse: def get_all_agent_types_with_instances( db: Session, user_id: UUID ) -> list[AgentTypeOverview]: - """Get all user agents with their instances for a specific user - OPTIMIZED""" - # Get all user agents for this user with instances in a single query + """Get all non-deleted user agents with their instances for a specific user - OPTIMIZED""" + # Get all non-deleted user agents for this user with instances in a single query user_agents = ( db.query(UserAgent) - .filter(UserAgent.user_id == user_id) + .filter(UserAgent.user_id == user_id, UserAgent.is_deleted.is_(False)) .options(subqueryload(UserAgent.instances)) .all() ) @@ -267,7 +267,9 @@ def get_agent_summary(db: Session, user_id: UUID) -> dict: ) .join(AgentInstance, AgentInstance.user_agent_id == UserAgent.id) .filter( - UserAgent.user_id == user_id, AgentInstance.status != AgentStatus.DELETED + UserAgent.user_id == user_id, + UserAgent.is_deleted.is_(False), + AgentInstance.status != AgentStatus.DELETED, ) .group_by(UserAgent.id, UserAgent.name, AgentInstance.status) .all() @@ -304,7 +306,11 @@ def get_agent_type_instances( user_agent = ( db.query(UserAgent) - .filter(UserAgent.id == agent_type_id, UserAgent.user_id == user_id) + .filter( + UserAgent.id == agent_type_id, + UserAgent.user_id == user_id, + UserAgent.is_deleted.is_(False), + ) .first() ) if not user_agent: diff --git a/backend/db/user_agent_queries.py b/backend/db/user_agent_queries.py index 08606f2..38e8547 100644 --- a/backend/db/user_agent_queries.py +++ b/backend/db/user_agent_queries.py @@ -29,10 +29,16 @@ def create_user_agent( ) -> dict | None: """Create a new user agent configuration""" - # Check if agent with same name already exists for this user + # Check if non-deleted agent with same name already exists for this user existing = ( db.query(UserAgent) - .filter(and_(UserAgent.user_id == user_id, UserAgent.name == request.name)) + .filter( + and_( + UserAgent.user_id == user_id, + UserAgent.name == request.name, + UserAgent.is_deleted.is_(False), + ) + ) .first() ) @@ -55,9 +61,13 @@ def create_user_agent( def get_user_agents(db: Session, user_id: UUID) -> list[dict]: - """Get all user agents for a specific user""" + """Get all non-deleted user agents for a specific user""" - user_agents = db.query(UserAgent).filter(UserAgent.user_id == user_id).all() + user_agents = ( + db.query(UserAgent) + .filter(and_(UserAgent.user_id == user_id, UserAgent.is_deleted.is_(False))) + .all() + ) return [_format_user_agent(agent, db) for agent in user_agents] @@ -69,7 +79,13 @@ def update_user_agent( user_agent = ( db.query(UserAgent) - .filter(and_(UserAgent.id == agent_id, UserAgent.user_id == user_id)) + .filter( + and_( + UserAgent.id == agent_id, + UserAgent.user_id == user_id, + UserAgent.is_deleted.is_(False), + ) + ) .first() ) @@ -266,10 +282,16 @@ async def trigger_webhook_agent( def get_user_agent_instances(db: Session, agent_id: UUID, user_id: UUID) -> list | None: """Get all instances for a specific user agent""" - # Verify the user agent exists and belongs to the user + # Verify the user agent exists, belongs to the user, and is not deleted user_agent = ( db.query(UserAgent) - .filter(and_(UserAgent.id == agent_id, UserAgent.user_id == user_id)) + .filter( + and_( + UserAgent.id == agent_id, + UserAgent.user_id == user_id, + UserAgent.is_deleted.is_(False), + ) + ) .first() ) @@ -293,12 +315,18 @@ def get_user_agent_instances(db: Session, agent_id: UUID, user_id: UUID) -> list def delete_user_agent(db: Session, agent_id: UUID, user_id: UUID) -> bool: - """Delete a user agent and all its associated instances and related data""" + """Soft delete a user agent and mark its instances as deleted, while removing messages""" - # First verify the user agent exists and belongs to the user + # First verify the user agent exists, belongs to the user, and is not already deleted user_agent = ( db.query(UserAgent) - .filter(and_(UserAgent.id == agent_id, UserAgent.user_id == user_id)) + .filter( + and_( + UserAgent.id == agent_id, + UserAgent.user_id == user_id, + UserAgent.is_deleted.is_(False), + ) + ) .first() ) @@ -310,16 +338,19 @@ def delete_user_agent(db: Session, agent_id: UUID, user_id: UUID) -> bool: db.query(AgentInstance).filter(AgentInstance.user_agent_id == agent_id).all() ) - # For each agent instance, delete all related data + # For each agent instance, delete all messages (for privacy/storage) for instance in agent_instances: - # Delete messages db.query(Message).filter(Message.agent_instance_id == instance.id).delete() - # Delete all agent instances - db.query(AgentInstance).filter(AgentInstance.user_agent_id == agent_id).delete() + # Mark all agent instances as DELETED + db.query(AgentInstance).filter(AgentInstance.user_agent_id == agent_id).update( + {"status": AgentStatus.DELETED} + ) + + # Soft delete the user agent + user_agent.is_deleted = True + user_agent.updated_at = datetime.now(timezone.utc) - # Delete the user agent - db.delete(user_agent) db.commit() return True diff --git a/servers/shared/db/queries.py b/servers/shared/db/queries.py index 55b9fd4..84a8f97 100644 --- a/servers/shared/db/queries.py +++ b/servers/shared/db/queries.py @@ -20,13 +20,18 @@ logger = logging.getLogger(__name__) def create_or_get_user_agent(db: Session, name: str, user_id: str) -> UserAgent: - """Create or get a user agent by name for a specific user""" + """Create or get a non-deleted user agent by name for a specific user""" # Normalize name to lowercase for consistent storage normalized_name = name.lower() + # Only look for non-deleted user agents user_agent = ( db.query(UserAgent) - .filter(UserAgent.name == normalized_name, UserAgent.user_id == UUID(user_id)) + .filter( + UserAgent.name == normalized_name, + UserAgent.user_id == UUID(user_id), + UserAgent.is_deleted.is_(False), + ) .first() ) if not user_agent: @@ -34,6 +39,7 @@ def create_or_get_user_agent(db: Session, name: str, user_id: str) -> UserAgent: name=normalized_name, user_id=UUID(user_id), is_active=True, + is_deleted=False, # Explicitly set to False for new agents ) db.add(user_agent) db.flush() # Flush to get the user_agent ID diff --git a/shared/alembic/versions/9f61865b8ba8_add_soft_delete_to_user_agents.py b/shared/alembic/versions/9f61865b8ba8_add_soft_delete_to_user_agents.py new file mode 100644 index 0000000..b1919b0 --- /dev/null +++ b/shared/alembic/versions/9f61865b8ba8_add_soft_delete_to_user_agents.py @@ -0,0 +1,67 @@ +"""Add soft delete to user agents + +Revision ID: 9f61865b8ba8 +Revises: 2e2f1b18e835 +Create Date: 2025-08-10 22:22:28.432623 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "9f61865b8ba8" +down_revision: Union[str, None] = "2e2f1b18e835" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # Add column as nullable first with server default + op.add_column( + "user_agents", + sa.Column( + "is_deleted", sa.Boolean(), server_default=sa.text("FALSE"), nullable=True + ), + ) + + # Set default value for existing rows (in case any are NULL) + op.execute("UPDATE user_agents SET is_deleted = FALSE WHERE is_deleted IS NULL") + + # Now make the column NOT NULL + op.alter_column( + "user_agents", "is_deleted", nullable=False, server_default=sa.text("FALSE") + ) + + op.drop_constraint( + op.f("uq_user_agents_user_id_name"), "user_agents", type_="unique" + ) + op.create_index( + "uq_user_agents_user_id_name", + "user_agents", + ["user_id", "name"], + unique=True, + postgresql_where=sa.text("is_deleted = FALSE"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "uq_user_agents_user_id_name", + table_name="user_agents", + postgresql_where=sa.text("is_deleted = FALSE"), + ) + op.create_unique_constraint( + op.f("uq_user_agents_user_id_name"), + "user_agents", + ["user_id", "name"], + postgresql_nulls_not_distinct=False, + ) + op.drop_column("user_agents", "is_deleted") + # ### end Alembic commands ### diff --git a/shared/database/models.py b/shared/database/models.py index cd74983..971b69e 100644 --- a/shared/database/models.py +++ b/shared/database/models.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from uuid import UUID, uuid4 from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint +from sqlalchemy import ForeignKey, Index, String, Text, text from sqlalchemy.dialects.postgresql import UUID as PostgresUUID, JSONB from sqlalchemy.orm import ( DeclarativeBase, # type: ignore[attr-defined] @@ -77,7 +77,14 @@ class User(Base): class UserAgent(Base): __tablename__ = "user_agents" __table_args__ = ( - UniqueConstraint("user_id", "name", name="uq_user_agents_user_id_name"), + # Partial unique index: only enforce uniqueness for non-deleted agents + Index( + "uq_user_agents_user_id_name", + "user_id", + "name", + unique=True, + postgresql_where=text("is_deleted = FALSE"), + ), Index("ix_user_agents_user_id", "user_id"), ) @@ -91,6 +98,7 @@ class UserAgent(Base): webhook_url: Mapped[str | None] = mapped_column(Text, default=None) webhook_api_key: Mapped[str | None] = mapped_column(Text, default=None) # Encrypted is_active: Mapped[bool] = mapped_column(default=True) + is_deleted: Mapped[bool] = mapped_column(default=False) # Soft delete flag created_at: Mapped[datetime] = mapped_column( default=lambda: datetime.now(timezone.utc) )