From f767f7df6010276bba40914fafd81cca7bb66b04 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 25 Feb 2026 13:41:59 -0500 Subject: [PATCH] feat: trigger step library sync on tree publish and deactivate on delete - Call sync_steps_from_tree in update_tree whenever the tree is published (status transitions to 'published' or is already published and structure changes) - Call deactivate_synced_steps_for_tree in delete_tree before db.commit() so the FK SET NULL does not nullify source_tree_id before the WHERE clause runs - Fix ::jsonb cast syntax in step_sync.py (asyncpg rejects :: operator in text() queries; replaced with CAST(:content AS jsonb)) - Add UniqueConstraint('source_tree_id','source_node_id') to StepLibrary model so Base.metadata.create_all (used by tests) creates the constraint that the ON CONFLICT clause in sync_steps_from_tree depends on Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/trees.py | 20 ++++++++++++++++++++ backend/app/core/step_sync.py | 2 +- backend/app/models/step_library.py | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index fb63f7e3..dbed1105 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -28,6 +28,7 @@ from app.core.subscriptions import check_tree_limit from app.core.audit import log_audit from app.core.config import settings from app.core.tree_validation import can_publish_tree +from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree router = APIRouter(prefix="/trees", tags=["trees"]) @@ -640,6 +641,21 @@ async def update_tree( if "tree_structure" in update_data: tree.version += 1 + # Sync steps to step library when publishing + if update_data.get("status") == 'published' or tree.status == 'published': + _structure = update_data.get("tree_structure", tree.tree_structure) + _type = update_data.get("tree_type", tree.tree_type) + _is_public = update_data.get("is_public", tree.is_public) + await sync_steps_from_tree( + db=db, + tree_id=tree.id, + tree_type=_type, + tree_structure=_structure, + author_id=tree.author_id, + account_id=tree.account_id, + is_public=_is_public, + ) + # Handle tags replacement if tags_data is not None: from app.models.tag import tree_tag_assignments @@ -753,6 +769,10 @@ async def delete_tree( tree_tag_assignments.delete().where(tree_tag_assignments.c.tree_id == tree.id) ) + # Deactivate any synced step library entries before deletion + # (must happen before db.delete/commit — FK SET NULL would lose the reference) + await deactivate_synced_steps_for_tree(db, tree.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/core/step_sync.py b/backend/app/core/step_sync.py index cd90325f..4a42066a 100644 --- a/backend/app/core/step_sync.py +++ b/backend/app/core/step_sync.py @@ -136,7 +136,7 @@ async def sync_steps_from_tree( helpful_yes, helpful_no, is_featured, is_verified, created_at, updated_at ) VALUES ( - gen_random_uuid(), :title, :step_type, :content::jsonb, + gen_random_uuid(), :title, :step_type, CAST(:content AS jsonb), :created_by, :account_id, :visibility, true, :source_tree_id, :source_node_id, :last_synced_at, '{}', true, diff --git a/backend/app/models/step_library.py b/backend/app/models/step_library.py index bd2b223e..e93c1f75 100644 --- a/backend/app/models/step_library.py +++ b/backend/app/models/step_library.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone from decimal import Decimal from typing import TYPE_CHECKING, Optional -from sqlalchemy import String, DateTime, Integer, Boolean, Text, Numeric, ForeignKey, CheckConstraint +from sqlalchemy import String, DateTime, Integer, Boolean, Text, Numeric, ForeignKey, CheckConstraint, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY from app.core.database import Base @@ -23,6 +23,7 @@ class StepLibrary(Base): "step_type IN ('decision', 'action', 'solution')", name='ck_step_library_step_type' ), + UniqueConstraint('source_tree_id', 'source_node_id', name='uq_step_library_source_node'), ) id: Mapped[uuid.UUID] = mapped_column(