Files
resolutionflow/backend/app/api/endpoints/shared.py
Michael Chihlas c7b2c59ef6 feat: implement tree sharing, draft trees, and session-to-tree conversion (Issues #16, #25, #17)
Backend features:
- Tree sharing via secure tokens with expiration (Issue #16)
- Draft tree status with conditional validation (Issue #25)
- Save session as custom tree with fork tracking (Issue #17)
- Tree validation system for publish requirements
- Session-to-tree conversion preserving custom steps

Database migrations:
- 024: Tree sharing (tree_shares table, visibility field)
- 025: Tree status field (draft/published)
- 25b: Merge migration for indexes

New endpoints:
- POST /api/v1/trees/{id}/share - Generate share token
- GET /api/v1/shared/{token} - Public tree access
- POST /api/v1/trees/{id}/can-publish - Validate tree
- POST /api/v1/sessions/{id}/save-as-tree - Convert session

Test coverage:
- 20 tests for draft trees functionality
- 14 tests for session-to-tree conversion
- 15 tests for tree sharing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 23:06:13 -05:00

71 lines
2.2 KiB
Python

"""Public endpoints for accessing shared content (no authentication required)."""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.tree import Tree
from app.models.tree_share import TreeShare
from app.schemas.tree import SharedTreeResponse
router = APIRouter(prefix="/shared", tags=["shared"])
@router.get("/{share_token}", response_model=SharedTreeResponse)
async def get_shared_tree(
share_token: str,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Get a tree by its share token (PUBLIC endpoint - no auth required).
Returns 404 if:
- Share token doesn't exist
- Share token has expired
- Tree is not active
"""
# Look up share token
result = await db.execute(
select(TreeShare)
.options(selectinload(TreeShare.tree).selectinload(Tree.tags))
.where(TreeShare.share_token == share_token)
)
tree_share = result.scalar_one_or_none()
if not tree_share:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link not found or has been revoked"
)
# Check expiration
if tree_share.expires_at and tree_share.expires_at < datetime.now(timezone.utc):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link has expired"
)
# Check tree is active
tree = tree_share.tree
if not tree or not tree.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Build response (minimal info for public access)
return SharedTreeResponse(
id=tree.id,
name=tree.name,
description=tree.description,
category=tree.category,
tree_structure=tree.tree_structure,
tags=tree.tag_names,
version=tree.version,
allow_forking=tree_share.allow_forking,
created_at=tree.created_at,
updated_at=tree.updated_at
)