diff --git a/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py b/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py index 39d7bf2dd..1da758c7d 100644 --- a/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py +++ b/surfsense_backend/alembic/versions/84_add_public_chat_snapshots_table.py @@ -11,6 +11,7 @@ Changes: - public_share_enabled (replaced by snapshot existence) - clone_pending (single-phase clone) 3. Drop related indexes +4. Add cloned_from_snapshot_id to new_chat_threads (tracks source snapshot for clones) """ from collections.abc import Sequence @@ -105,11 +106,32 @@ def upgrade() -> None: op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_enabled") op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS public_share_token") + # 6. Add cloned_from_snapshot_id to new_chat_threads + op.execute( + """ + ALTER TABLE new_chat_threads + ADD COLUMN IF NOT EXISTS cloned_from_snapshot_id INTEGER + REFERENCES public_chat_snapshots(id) ON DELETE SET NULL; + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_new_chat_threads_cloned_from_snapshot_id + ON new_chat_threads(cloned_from_snapshot_id) + WHERE cloned_from_snapshot_id IS NOT NULL; + """ + ) + def downgrade() -> None: """Restore deprecated columns and drop public_chat_snapshots table.""" - # 1. Restore deprecated columns on new_chat_threads + # 1. Drop cloned_from_snapshot_id column and index + op.execute("DROP INDEX IF EXISTS ix_new_chat_threads_cloned_from_snapshot_id") + op.execute("ALTER TABLE new_chat_threads DROP COLUMN IF EXISTS cloned_from_snapshot_id") + + # 2. Restore deprecated columns on new_chat_threads op.execute( """ ALTER TABLE new_chat_threads diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 825ab93eb..3abdf1c9f 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -418,6 +418,12 @@ class NewChatThread(BaseModel, TimestampMixin): nullable=True, index=True, ) + cloned_from_snapshot_id = Column( + Integer, + ForeignKey("public_chat_snapshots.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) cloned_at = Column( TIMESTAMP(timezone=True), nullable=True, @@ -443,6 +449,7 @@ class NewChatThread(BaseModel, TimestampMixin): "PublicChatSnapshot", back_populates="thread", cascade="all, delete-orphan", + foreign_keys="[PublicChatSnapshot.thread_id]", ) @@ -491,12 +498,6 @@ class PublicChatSnapshot(BaseModel, TimestampMixin): Each snapshot is a frozen copy of the chat at a specific point in time. The snapshot_data JSONB contains all messages and metadata needed to render the public chat without querying the original thread. - - Key features: - - Immutable: Content never changes after creation - - Deduplication: content_hash prevents duplicate snapshots of same state - - Cascade delete: Deleted when parent thread is deleted - - Message tracking: message_ids array enables cascade delete on message edit """ __tablename__ = "public_chat_snapshots" @@ -517,36 +518,16 @@ class PublicChatSnapshot(BaseModel, TimestampMixin): index=True, ) - # SHA-256 hash of message content for deduplication - # Same content = same hash = return existing snapshot instead of creating new content_hash = Column( String(64), nullable=False, index=True, ) - # Immutable snapshot data - self-contained for rendering - # Structure: - # { - # "version": 1, - # "title": "Chat title", - # "snapshot_at": "2026-01-29T12:00:00Z", - # "author": { "display_name": "...", "avatar_url": "..." }, - # "messages": [ - # { "id": 123, "role": "user|assistant", "content": [...], "author": {...}, "created_at": "..." } - # ], - # "podcasts": [ - # { "original_id": 456, "title": "...", "transcript": "...", "file_path": "..." } - # ] - # } snapshot_data = Column(JSONB, nullable=False) - # Array of message IDs included in this snapshot - # Used for cascade deletion when messages are edited/deleted - # GIN index enables fast array overlap queries message_ids = Column(ARRAY(Integer), nullable=False) - # Who created this snapshot created_by_user_id = Column( UUID(as_uuid=True), ForeignKey("user.id", ondelete="SET NULL"), @@ -555,7 +536,11 @@ class PublicChatSnapshot(BaseModel, TimestampMixin): ) # Relationships - thread = relationship("NewChatThread", back_populates="snapshots") + thread = relationship( + "NewChatThread", + back_populates="snapshots", + foreign_keys="[PublicChatSnapshot.thread_id]", + ) created_by = relationship("User") # Constraints diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 8dde5e73a..f0d8eb18e 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -523,6 +523,7 @@ async def clone_from_snapshot( search_space_id=target_search_space_id, created_by_id=user.id, cloned_from_thread_id=snapshot.thread_id, + cloned_from_snapshot_id=snapshot.id, cloned_at=datetime.now(UTC), needs_history_bootstrap=True, )