From 33368688b216e4ffcb9f6916f7750e5d0da9d2cc Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 5 Feb 2026 23:33:05 -0500 Subject: [PATCH] feat: upgrade tree deletion to soft delete with deleted_at timestamp Adds deleted_at and deleted_by columns to trees table for proper soft delete tracking. Supports future 30-day restore window functionality. The delete endpoint now sets both is_active=False (backward compat) and deleted_at/deleted_by. Migration backfills existing is_active=False rows. Fixed ambiguous FK relationship between User/Tree models by adding explicit foreign_keys to both sides of the author relationship. Co-Authored-By: Claude Opus 4.6 --- .../versions/015_add_deleted_at_to_trees.py | 38 +++++++++++++++++++ backend/app/api/endpoints/trees.py | 5 ++- backend/app/models/tree.py | 12 +++++- backend/app/models/user.py | 2 +- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/015_add_deleted_at_to_trees.py diff --git a/backend/alembic/versions/015_add_deleted_at_to_trees.py b/backend/alembic/versions/015_add_deleted_at_to_trees.py new file mode 100644 index 00000000..ac88aeeb --- /dev/null +++ b/backend/alembic/versions/015_add_deleted_at_to_trees.py @@ -0,0 +1,38 @@ +"""add deleted_at and deleted_by columns to trees + +Revision ID: 015 +Revises: 014 +Create Date: 2026-02-05 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = '015' +down_revision: Union[str, None] = '014' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('trees', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('trees', sa.Column('deleted_by', UUID(as_uuid=True), nullable=True)) + op.create_index('ix_trees_deleted_at', 'trees', ['deleted_at']) + op.create_foreign_key('fk_trees_deleted_by_users', 'trees', 'users', ['deleted_by'], ['id']) + + # Backfill: convert existing is_active=False to deleted_at=now() + op.execute("UPDATE trees SET deleted_at = NOW() WHERE is_active = FALSE") + + +def downgrade() -> None: + # Convert back + op.execute("UPDATE trees SET is_active = FALSE WHERE deleted_at IS NOT NULL") + op.drop_constraint('fk_trees_deleted_by_users', 'trees', type_='foreignkey') + op.drop_index('ix_trees_deleted_at', table_name='trees') + op.drop_column('trees', 'deleted_by') + op.drop_column('trees', 'deleted_at') diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index a246e8ab..53906793 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status, Query @@ -508,7 +509,7 @@ async def delete_tree( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)] ): - """Soft delete a tree (admin only). Sets is_active to False.""" + """Soft delete a tree (admin only). Sets deleted_at timestamp and is_active=False.""" result = await db.execute(select(Tree).where(Tree.id == tree_id)) tree = result.scalar_one_or_none() @@ -519,6 +520,8 @@ async def delete_tree( ) tree.is_active = False + tree.deleted_at = datetime.now(timezone.utc) + tree.deleted_by = current_user.id await log_audit(db, current_user.id, "tree.delete", "tree", tree.id, {"tree_name": tree.name}) await db.commit() diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 7551ccf7..56237d96 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -50,6 +50,16 @@ class Tree(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + index=True + ) + deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True + ) version: Mapped[int] = mapped_column(Integer, default=1) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -63,7 +73,7 @@ class Tree(Base): usage_count: Mapped[int] = mapped_column(Integer, default=0) # Relationships - author: Mapped[Optional["User"]] = relationship("User", back_populates="trees") + author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees") team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees") sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 05aab42a..ac7648f7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -52,7 +52,7 @@ class User(Base): # Relationships team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users") - trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="author") + trees: Mapped[list["Tree"]] = relationship("Tree", foreign_keys="[Tree.author_id]", back_populates="author") sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user") folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user")