Add foundational domain model design document
Comprehensive schema design for three critical foundational features: 1. Tree Forking Model (Issue #11) - Add parent_tree_id, fork_reason, parent_updated_at to trees - Self-referential relationship with orphaning on parent delete - Update detection mechanism for "parent tree updated" notifications 2. Session Custom Steps Enhancement (Issues #4-#7 partial) - Backward-compatible JSONB enhancement (no migration) - Track step source (ad-hoc, library, forked-tree) - Link to StepLibrary for usage analytics - Support "save session as tree" reconstruction 3. Session Share Tokens (Issue #15) - New session_shares table with token-based access - New session_share_views table for detailed analytics - Account-level policy: allow_public_shares - Public vs account-only visibility with permission checks All schema changes designed for backward compatibility and minimal migration complexity. Establishes durable domain model that future features depend on. Migration plan: 022 (tree forking), 023 (session sharing) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
834
docs/plans/2026-02-07-foundational-schema-design.md
Normal file
834
docs/plans/2026-02-07-foundational-schema-design.md
Normal file
@@ -0,0 +1,834 @@
|
||||
# Foundational Domain Model: Step Library, User Trees, and Session Sharing
|
||||
|
||||
## Context
|
||||
|
||||
This design establishes the foundational schema for three critical ResolutionFlow features that define the durable domain model. All future functionality depends on these tables, fields, and constraints being correctly designed from the start.
|
||||
|
||||
### Features Covered
|
||||
|
||||
1. **Step Library Core Schema** (Issues #4-#7): Reusable troubleshooting steps with categories, ratings, and usage tracking
|
||||
2. **User Trees & Forking** (Issue #11): Personal/forked tree data model for engineer customization
|
||||
3. **Session Sharing** (Issue #15): Read-only share links with configurable access control
|
||||
|
||||
### Why These First
|
||||
|
||||
These features establish:
|
||||
- **Step Library**: Canonical data model for reusable steps before any UI or session integrations
|
||||
- **User Trees**: Fork relationships and ownership model before implementing fork/share workflows
|
||||
- **Sharing Schema**: Token mechanics and access control before APIs/UI solidify
|
||||
|
||||
Getting these schemas right now prevents costly migrations and refactoring later.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Tree Forking Model
|
||||
|
||||
### Design Principle
|
||||
|
||||
**Forked trees are regular trees with added fork metadata.** They use the same ownership/visibility model as existing trees:
|
||||
- `author_id`: Engineer who created the fork
|
||||
- `account_id`: Account that owns the fork
|
||||
- `is_public` / visibility: Controls who can see/use it
|
||||
|
||||
### Schema Changes: New Fields on `trees` Table
|
||||
|
||||
Add three fields to track fork relationships:
|
||||
|
||||
```python
|
||||
# Fork relationship tracking
|
||||
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"), # Orphan forks on parent delete
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
fork_reason: Mapped[Optional[str]] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Brief reason: 'Added Cisco Meraki steps for our network'"
|
||||
)
|
||||
|
||||
parent_updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Snapshot of parent's updated_at when fork created. Compare to detect parent updates."
|
||||
)
|
||||
```
|
||||
|
||||
### New Relationships on `Tree` Model
|
||||
|
||||
```python
|
||||
# In Tree class:
|
||||
parent: Mapped[Optional["Tree"]] = relationship(
|
||||
"Tree",
|
||||
remote_side=[id],
|
||||
foreign_keys=[parent_tree_id],
|
||||
back_populates="forks"
|
||||
)
|
||||
|
||||
forks: Mapped[list["Tree"]] = relationship(
|
||||
"Tree",
|
||||
foreign_keys=[parent_tree_id],
|
||||
back_populates="parent"
|
||||
# No cascade - ondelete="SET NULL" handles orphaning at DB level
|
||||
)
|
||||
```
|
||||
|
||||
### Field Explanations
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `parent_tree_id` | UUID nullable | NULL = root tree, not-NULL = forked tree. Points to original tree. |
|
||||
| `fork_reason` | String(255) | Optional engineer note: "Added wireless troubleshooting for our Meraki APs" |
|
||||
| `parent_updated_at` | Datetime nullable | Timestamp snapshot. Compare to parent's actual `updated_at` to detect changes. |
|
||||
|
||||
### Key Behaviors
|
||||
|
||||
**Fork Creation:**
|
||||
1. Copy parent's `tree_structure` JSONB
|
||||
2. Set `parent_tree_id` to parent's ID
|
||||
3. Set `fork_reason` from user input
|
||||
4. Snapshot `parent_updated_at = parent.updated_at`
|
||||
5. Set `author_id = current_user.id`
|
||||
6. Set `account_id = current_user.account_id`
|
||||
7. Default `is_public = False` (private fork)
|
||||
8. Reset `version = 1` for fork
|
||||
|
||||
**Fork Updates:**
|
||||
- Engineer edits fork → `version` increments
|
||||
- Parent tree unaffected
|
||||
- `parent_updated_at` remains frozen at fork time
|
||||
|
||||
**Parent Deletion:**
|
||||
- Hard delete → `parent_tree_id` becomes NULL (orphaned)
|
||||
- Fork survives as independent tree
|
||||
- Soft delete → `parent_tree_id` preserved, fork still references parent
|
||||
|
||||
**Update Notifications:**
|
||||
```python
|
||||
def has_parent_updates(fork: Tree) -> bool:
|
||||
"""Check if parent tree has updates since fork."""
|
||||
if not fork.parent_tree_id or not fork.parent_updated_at:
|
||||
return False
|
||||
|
||||
parent = db.query(Tree).get(fork.parent_tree_id)
|
||||
if not parent:
|
||||
return False # Parent deleted or soft-deleted
|
||||
|
||||
return parent.updated_at > fork.parent_updated_at
|
||||
```
|
||||
|
||||
### Migration 1: Tree Forking Fields
|
||||
|
||||
**File**: `alembic/versions/022_add_tree_forking.py`
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Add fork tracking columns
|
||||
op.add_column('trees', sa.Column('parent_tree_id', UUID, nullable=True))
|
||||
op.add_column('trees', sa.Column('fork_reason', sa.String(255), nullable=True))
|
||||
op.add_column('trees', sa.Column('parent_updated_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# Add foreign key
|
||||
op.create_foreign_key(
|
||||
'fk_trees_parent_tree_id',
|
||||
'trees', 'trees',
|
||||
['parent_tree_id'], ['id'],
|
||||
ondelete='SET NULL'
|
||||
)
|
||||
|
||||
# Add index for fork queries
|
||||
op.create_index('ix_trees_parent_tree_id', 'trees', ['parent_tree_id'])
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_trees_parent_tree_id')
|
||||
op.drop_constraint('fk_trees_parent_tree_id', 'trees')
|
||||
op.drop_column('trees', 'parent_updated_at')
|
||||
op.drop_column('trees', 'fork_reason')
|
||||
op.drop_column('trees', 'parent_tree_id')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Session Custom Steps Enhancement
|
||||
|
||||
### Current State
|
||||
|
||||
`sessions.custom_steps` already exists as JSONB field. Current structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"custom_steps": [
|
||||
{
|
||||
"type": "action",
|
||||
"content": "Check Meraki dashboard for AP status",
|
||||
"notes": "Found AP offline"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Enhanced Structure (Backward Compatible)
|
||||
|
||||
**No migration needed** - pure JSONB enhancement:
|
||||
|
||||
```json
|
||||
{
|
||||
"custom_steps": [
|
||||
{
|
||||
"type": "action",
|
||||
"content": "Check Meraki dashboard for AP status",
|
||||
"notes": "Found AP offline",
|
||||
|
||||
// NEW FIELDS (optional, added to new sessions only):
|
||||
"source": "ad-hoc", // "ad-hoc" | "step-library" | "forked-tree"
|
||||
"source_step_id": null, // UUID string if from StepLibrary
|
||||
"inserted_at": "2026-02-07T15:30:00Z", // ISO datetime
|
||||
"inserted_after_node_id": "ad_verify_identity" // Node ID from tree_structure
|
||||
},
|
||||
{
|
||||
"type": "action",
|
||||
"content": "Verify DNS configuration",
|
||||
"notes": "DNS servers correct",
|
||||
"source": "step-library",
|
||||
"source_step_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"inserted_at": "2026-02-07T15:32:00Z",
|
||||
"inserted_after_node_id": "network_check"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### New Field Definitions
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `source` | string | Origin: `ad-hoc` (typed by engineer), `step-library` (from library), `forked-tree` (from fork) |
|
||||
| `source_step_id` | UUID string | If `source=step-library`, points to StepLibrary.id for usage tracking |
|
||||
| `inserted_at` | ISO datetime | When engineer added this step to session |
|
||||
| `inserted_after_node_id` | string | Node ID from tree_structure where step was inserted (e.g., `"ad_verify_identity"`) |
|
||||
|
||||
### Use Cases
|
||||
|
||||
**1. Step Library Usage Tracking**
|
||||
- Engineer inserts step from library → logs to `step_usage_log`
|
||||
- Increments `StepLibrary.usage_count`
|
||||
- Even if step later deleted, `source_step_id` preserved (orphaned reference for analytics)
|
||||
|
||||
**2. Identify Popular Ad-Hoc Steps**
|
||||
- Analytics query: "What ad-hoc steps appear across multiple sessions?"
|
||||
- Example: "Check Meraki dashboard" appears 15 times → suggest adding to library
|
||||
- Helps identify gaps in official step library
|
||||
|
||||
**3. Save Session as Tree (Future)**
|
||||
- Engineer completes session with custom steps
|
||||
- "Save as Tree" reconstructs tree structure
|
||||
- Uses `inserted_after_node_id` to place custom steps correctly
|
||||
|
||||
### Backward Compatibility Strategy
|
||||
|
||||
**Pydantic schema** (backend):
|
||||
```python
|
||||
class CustomStepSchema(BaseModel):
|
||||
type: str # "decision" | "action" | "solution"
|
||||
content: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
# New fields with defaults for old sessions:
|
||||
source: str = "ad-hoc"
|
||||
source_step_id: Optional[UUID] = None
|
||||
inserted_at: Optional[datetime] = None
|
||||
inserted_after_node_id: Optional[str] = None
|
||||
```
|
||||
|
||||
**Result**: Old sessions load without error. New fields auto-populate with defaults in-memory. Database unchanged.
|
||||
|
||||
### Step Library Reference Handling
|
||||
|
||||
**When StepLibrary entry deleted:**
|
||||
- `source_step_id` remains in sessions (orphaned UUID)
|
||||
- No foreign key constraint (it's in JSONB)
|
||||
- Analytics can still count: "Deleted step XYZ was used 50 times historically"
|
||||
- Frontend shows: "From step library (no longer available)"
|
||||
|
||||
### Usage Tracking Implementation
|
||||
|
||||
**When engineer inserts library step into session:**
|
||||
|
||||
```python
|
||||
async def insert_step_from_library(
|
||||
session_id: UUID,
|
||||
step_id: UUID,
|
||||
current_node_id: str,
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# 1. Load step from library
|
||||
step = await db.get(StepLibrary, step_id)
|
||||
|
||||
# 2. Add to session.custom_steps
|
||||
new_step = {
|
||||
"type": step.step_type,
|
||||
"content": step.content,
|
||||
"notes": "",
|
||||
"source": "step-library",
|
||||
"source_step_id": str(step.id),
|
||||
"inserted_at": datetime.now(timezone.utc).isoformat(),
|
||||
"inserted_after_node_id": current_node_id
|
||||
}
|
||||
|
||||
session.custom_steps.append(new_step)
|
||||
|
||||
# 3. Log usage immediately
|
||||
usage_log = StepUsageLog(
|
||||
step_id=step.id,
|
||||
user_id=current_user.id,
|
||||
session_id=session.id,
|
||||
used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(usage_log)
|
||||
|
||||
# 4. Increment counter
|
||||
step.usage_count += 1
|
||||
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
**Multiple insertions**: Same step inserted twice in one session → 2 `StepUsageLog` entries, 2 items in `custom_steps` array, `usage_count += 2`.
|
||||
|
||||
### No Migration Required
|
||||
|
||||
✅ This is pure application-layer enhancement. Existing sessions remain valid. New sessions use enhanced structure.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Session Share Tokens
|
||||
|
||||
### Overview
|
||||
|
||||
Enable engineers to share read-only session links with configurable access control:
|
||||
- **Public shares**: Anyone with link can view (no auth required)
|
||||
- **Account-only shares**: Requires login + account membership
|
||||
|
||||
Account owners control whether engineers can create public shares via policy setting.
|
||||
|
||||
### New Table 1: `session_shares`
|
||||
|
||||
```python
|
||||
class SessionShare(Base):
|
||||
__tablename__ = "session_shares"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"visibility IN ('public', 'account')",
|
||||
name='ck_session_shares_visibility'
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
share_token: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="URL-safe random token (48 bytes → 64 base64 chars)"
|
||||
)
|
||||
|
||||
share_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(100),
|
||||
nullable=True,
|
||||
comment="Optional label: 'Training link', 'Customer escalation #1234'"
|
||||
)
|
||||
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="public",
|
||||
comment="public = anyone with link, account = account members only"
|
||||
)
|
||||
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True # For "My Shares" view performance
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Optional expiration for time-limited shares"
|
||||
)
|
||||
|
||||
# Deprecated: Simple counting replaced by session_share_views table
|
||||
view_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0
|
||||
)
|
||||
|
||||
last_viewed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
session: Mapped["Session"] = relationship("Session", back_populates="shares")
|
||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
|
||||
views: Mapped[list["SessionShareView"]] = relationship(
|
||||
"SessionShareView",
|
||||
back_populates="share",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
```
|
||||
|
||||
### New Table 2: `session_share_views`
|
||||
|
||||
Track detailed view analytics, including WHO viewed account-only shares:
|
||||
|
||||
```python
|
||||
class SessionShareView(Base):
|
||||
__tablename__ = "session_share_views"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
|
||||
share_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("session_shares.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
viewer_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="NULL for public shares (unauthenticated views)"
|
||||
)
|
||||
|
||||
viewed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
index=True
|
||||
)
|
||||
|
||||
viewer_ip: Mapped[Optional[str]] = mapped_column(
|
||||
String(45), # IPv6 max length
|
||||
nullable=True
|
||||
)
|
||||
|
||||
viewer_user_agent: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
share: Mapped["SessionShare"] = relationship("SessionShare", back_populates="views")
|
||||
viewer: Mapped[Optional["User"]] = relationship("User")
|
||||
```
|
||||
|
||||
### Account-Level Sharing Policy
|
||||
|
||||
Add to `accounts` table:
|
||||
|
||||
```python
|
||||
# In Account model:
|
||||
allow_public_shares: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Policy: engineers can create public shares. Only affects NEW shares (grandfathered)."
|
||||
)
|
||||
```
|
||||
|
||||
**Policy enforcement** (at share creation time only):
|
||||
```python
|
||||
# In create_share endpoint:
|
||||
if visibility == "public" and not current_user.account.allow_public_shares:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Your organization does not allow public session sharing. Use account-only visibility."
|
||||
)
|
||||
```
|
||||
|
||||
**Existing shares**: If account owner toggles policy OFF, existing public shares remain active (grandfathered). Owner can manually revoke individual shares if needed.
|
||||
|
||||
### Share Token Generation
|
||||
|
||||
**Requirements:**
|
||||
- URL-safe (no special chars)
|
||||
- Cryptographically random (non-guessable)
|
||||
- Collision-resistant
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
import secrets
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
async def create_session_share(
|
||||
db: AsyncSession,
|
||||
session_id: UUID,
|
||||
created_by: UUID,
|
||||
visibility: str,
|
||||
share_name: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
max_retries: int = 3
|
||||
) -> SessionShare:
|
||||
"""Create share with automatic token collision retry."""
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
share_token = secrets.token_urlsafe(48) # 48 bytes → 64 chars
|
||||
|
||||
share = SessionShare(
|
||||
session_id=session_id,
|
||||
share_token=share_token,
|
||||
share_name=share_name,
|
||||
visibility=visibility,
|
||||
created_by=created_by,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
db.add(share)
|
||||
await db.commit()
|
||||
await db.refresh(share)
|
||||
return share
|
||||
|
||||
except IntegrityError as e:
|
||||
if "session_shares_share_token_key" in str(e):
|
||||
# Token collision (extremely rare), retry
|
||||
await db.rollback()
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
**Share URL format:**
|
||||
```
|
||||
https://patherly.com/share/x3K9mN_2pQ7vR8sT4wZ1aB5cD6eF7gH8iJ9kL0mN
|
||||
```
|
||||
|
||||
### Access Control Flow
|
||||
|
||||
**When user visits share link:**
|
||||
|
||||
```python
|
||||
async def access_share(
|
||||
token: str,
|
||||
current_user: Optional[User],
|
||||
request: Request,
|
||||
db: AsyncSession
|
||||
) -> SessionResponse:
|
||||
# 1. Lookup share
|
||||
share = await db.execute(
|
||||
select(SessionShare)
|
||||
.where(SessionShare.share_token == token)
|
||||
.options(joinedload(SessionShare.session))
|
||||
)
|
||||
share = share.scalar_one_or_none()
|
||||
|
||||
# 2. Validate share
|
||||
if not share or not share.is_active:
|
||||
raise HTTPException(404, "Share not found or has been revoked")
|
||||
|
||||
if share.expires_at and share.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(410, "Share link has expired")
|
||||
|
||||
# 3. Check visibility
|
||||
if share.visibility == "account":
|
||||
if not current_user:
|
||||
raise HTTPException(401, "This share requires authentication")
|
||||
|
||||
# Check account membership
|
||||
session_owner = await db.get(User, share.session.user_id)
|
||||
if current_user.account_id != session_owner.account_id:
|
||||
raise HTTPException(403, "You don't have access to this session")
|
||||
|
||||
# 4. Record view
|
||||
view = SessionShareView(
|
||||
share_id=share.id,
|
||||
viewer_id=current_user.id if current_user else None,
|
||||
viewed_at=datetime.now(timezone.utc),
|
||||
viewer_ip=request.client.host,
|
||||
viewer_user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
db.add(view)
|
||||
|
||||
share.last_viewed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
# 5. Return read-only session view
|
||||
return SessionResponse.from_orm(share.session)
|
||||
```
|
||||
|
||||
### Share Management Permissions
|
||||
|
||||
**Create share**: `POST /api/v1/sessions/{session_id}/shares`
|
||||
- **Requires**: `session.user_id == current_user.id` (session owner only)
|
||||
- **Validates**: `account.allow_public_shares` if `visibility='public'`
|
||||
|
||||
**Revoke share**: `DELETE /api/v1/shares/{share_id}`
|
||||
- **Requires**: `share.created_by == current_user.id` (share creator only)
|
||||
- Sets `is_active = False` (soft revoke)
|
||||
|
||||
**List my shares**: `GET /api/v1/shares/my-shares`
|
||||
- Returns: `WHERE created_by == current_user.id`
|
||||
- Uses `created_by` index for performance
|
||||
|
||||
### Multiple Shares Per Session
|
||||
|
||||
✅ **Supported**: No unique constraint on `session_id`. Engineer can create:
|
||||
- Public link for customer
|
||||
- Account-only link for internal team
|
||||
- Time-limited link (24h) for contractor
|
||||
- Named links: "Training link for new hires", "Escalation to senior"
|
||||
|
||||
### Cleanup Job for Expired Shares
|
||||
|
||||
**Background task** (runs daily via cron/scheduler):
|
||||
|
||||
```python
|
||||
async def cleanup_expired_shares(
|
||||
db: AsyncSession,
|
||||
retention_days: int = 30
|
||||
):
|
||||
"""Hard-delete expired shares after retention period."""
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
|
||||
# Find shares expired > retention_days ago
|
||||
expired_shares = await db.execute(
|
||||
select(SessionShare)
|
||||
.where(SessionShare.expires_at < cutoff)
|
||||
.where(SessionShare.is_active == False)
|
||||
)
|
||||
|
||||
for share in expired_shares.scalars():
|
||||
await db.delete(share) # Cascades to session_share_views
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Cleaned up {len(list(expired_shares))} expired shares")
|
||||
```
|
||||
|
||||
### Migration 2: Session Sharing
|
||||
|
||||
**File**: `alembic/versions/023_add_session_sharing.py`
|
||||
|
||||
```python
|
||||
def upgrade():
|
||||
# Create session_shares table
|
||||
op.create_table(
|
||||
'session_shares',
|
||||
sa.Column('id', UUID, primary_key=True),
|
||||
sa.Column('session_id', UUID, nullable=False),
|
||||
sa.Column('share_token', sa.String(64), nullable=False, unique=True),
|
||||
sa.Column('share_name', sa.String(100), nullable=True),
|
||||
sa.Column('visibility', sa.String(20), nullable=False),
|
||||
sa.Column('created_by', UUID, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('view_count', sa.Integer, nullable=False, server_default='0'),
|
||||
sa.Column('last_viewed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean, nullable=False, server_default='true'),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.CheckConstraint("visibility IN ('public', 'account')", name='ck_session_shares_visibility')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_session_shares_session_id', 'session_shares', ['session_id'])
|
||||
op.create_index('ix_session_shares_share_token', 'session_shares', ['share_token'])
|
||||
op.create_index('ix_session_shares_created_by', 'session_shares', ['created_by'])
|
||||
op.create_index('ix_session_shares_expires_at', 'session_shares', ['expires_at'])
|
||||
op.create_index('ix_session_shares_is_active', 'session_shares', ['is_active'])
|
||||
|
||||
# Create session_share_views table
|
||||
op.create_table(
|
||||
'session_share_views',
|
||||
sa.Column('id', UUID, primary_key=True),
|
||||
sa.Column('share_id', UUID, nullable=False),
|
||||
sa.Column('viewer_id', UUID, nullable=True),
|
||||
sa.Column('viewed_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('viewer_ip', sa.String(45), nullable=True),
|
||||
sa.Column('viewer_user_agent', sa.String(500), nullable=True),
|
||||
sa.ForeignKeyConstraint(['share_id'], ['session_shares.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['viewer_id'], ['users.id'], ondelete='SET NULL')
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_session_share_views_share_id', 'session_share_views', ['share_id'])
|
||||
op.create_index('ix_session_share_views_viewer_id', 'session_share_views', ['viewer_id'])
|
||||
op.create_index('ix_session_share_views_viewed_at', 'session_share_views', ['viewed_at'])
|
||||
|
||||
# Add account policy
|
||||
op.add_column('accounts', sa.Column('allow_public_shares', sa.Boolean, nullable=False, server_default='true'))
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('accounts', 'allow_public_shares')
|
||||
op.drop_table('session_share_views')
|
||||
op.drop_table('session_shares')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Schema Changes
|
||||
|
||||
### Migration 022: Tree Forking
|
||||
**New columns on `trees`:**
|
||||
- `parent_tree_id` (UUID nullable, FK to trees.id, SET NULL on delete)
|
||||
- `fork_reason` (String 255)
|
||||
- `parent_updated_at` (Datetime nullable)
|
||||
|
||||
**Indexes:**
|
||||
- `ix_trees_parent_tree_id`
|
||||
|
||||
### Migration 023: Session Sharing
|
||||
**New tables:**
|
||||
- `session_shares` (13 columns, 5 indexes)
|
||||
- `session_share_views` (6 columns, 3 indexes)
|
||||
|
||||
**New columns on `accounts`:**
|
||||
- `allow_public_shares` (Boolean, default true)
|
||||
|
||||
### No Migration: Custom Steps Enhancement
|
||||
Pure JSONB enhancement with backward compatibility. Existing sessions remain valid.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Tree Forking (Issues #11)
|
||||
- [ ] Create migration 022 (tree forking fields)
|
||||
- [ ] Update Tree model with new fields and relationships
|
||||
- [ ] Add fork creation endpoint: `POST /api/v1/trees/{tree_id}/fork`
|
||||
- [ ] Add fork list endpoint: `GET /api/v1/trees/{tree_id}/forks`
|
||||
- [ ] Implement parent update detection logic
|
||||
- [ ] Frontend: Fork button on tree detail page
|
||||
- [ ] Frontend: "Parent updated" notification badge
|
||||
- [ ] Tests: Fork creation, orphaning, update detection
|
||||
|
||||
### Phase 2: Custom Steps Enhancement (Issues #4-#7 partial)
|
||||
- [ ] Update CustomStepSchema with new optional fields
|
||||
- [ ] Implement step insertion from library endpoint
|
||||
- [ ] Create StepUsageLog entry on insertion
|
||||
- [ ] Increment StepLibrary.usage_count
|
||||
- [ ] Analytics endpoint: Popular ad-hoc steps
|
||||
- [ ] Frontend: Insert from library button in session
|
||||
- [ ] Tests: Usage tracking, backward compatibility
|
||||
|
||||
### Phase 3: Session Sharing (Issue #15)
|
||||
- [ ] Create migration 023 (session sharing tables)
|
||||
- [ ] Update SessionShare and SessionShareView models
|
||||
- [ ] Add Account.allow_public_shares field
|
||||
- [ ] Implement share creation endpoint with token retry
|
||||
- [ ] Implement share access endpoint with view tracking
|
||||
- [ ] Implement share revocation endpoint
|
||||
- [ ] Setup cleanup job for expired shares
|
||||
- [ ] Frontend: Share button in session detail
|
||||
- [ ] Frontend: Share management modal
|
||||
- [ ] Frontend: Public share view page
|
||||
- [ ] Tests: Share creation, access control, expiration
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Tree Forking Tests
|
||||
- [ ] Create fork from tree → `parent_tree_id` set correctly
|
||||
- [ ] Update parent tree → fork unaffected
|
||||
- [ ] Delete parent tree (hard) → fork orphaned (`parent_tree_id = NULL`)
|
||||
- [ ] Soft delete parent → fork still references parent
|
||||
- [ ] `has_parent_updates()` returns true after parent updated
|
||||
- [ ] `has_parent_updates()` returns false if parent unchanged
|
||||
- [ ] Fork reason stored and retrieved correctly
|
||||
|
||||
### Custom Steps Tests
|
||||
- [ ] Old session loads without error (backward compat)
|
||||
- [ ] Insert step from library → `source_step_id` populated
|
||||
- [ ] Insert step from library → `StepUsageLog` created
|
||||
- [ ] Insert step from library → `usage_count` incremented
|
||||
- [ ] Insert same step twice → 2 log entries, count += 2
|
||||
- [ ] Delete step → `source_step_id` remains (orphaned)
|
||||
- [ ] Ad-hoc step → `source = "ad-hoc"`, `source_step_id = null`
|
||||
|
||||
### Session Sharing Tests
|
||||
- [ ] Create public share → token generated, unique
|
||||
- [ ] Access public share without auth → works
|
||||
- [ ] Create account-only share → requires auth
|
||||
- [ ] Access account-only share as outsider → 403 error
|
||||
- [ ] Access account-only share as member → works, view logged
|
||||
- [ ] Account with `allow_public_shares=False` → cannot create public share
|
||||
- [ ] Expired share → 410 error
|
||||
- [ ] Revoked share → 404 error
|
||||
- [ ] Multiple shares per session → all work independently
|
||||
- [ ] View tracking → `SessionShareView` entries created
|
||||
- [ ] Delete session → shares cascade deleted
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
**Tree Forking:**
|
||||
- Merge fork changes back to parent (pull request model)
|
||||
- Visual diff view: fork vs parent tree
|
||||
- Bulk fork operations: fork all trees in category
|
||||
|
||||
**Custom Steps:**
|
||||
- One-click "Promote to library" for ad-hoc steps
|
||||
- Auto-suggest library steps based on session context
|
||||
- "Save session as tree" reconstruction
|
||||
|
||||
**Session Sharing:**
|
||||
- Embed shares in iframe (oEmbed support)
|
||||
- Password-protected shares
|
||||
- Share groups (one link, multiple sessions)
|
||||
- Share templates with pre-filled expiration/visibility
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Issues**: #4, #5, #6, #7 (Step Library), #11 (User Trees), #15 (Sharing)
|
||||
- **Existing Models**: Tree, Session, StepLibrary, StepRating, StepUsageLog
|
||||
- **Feature Brainstorm**: `docs/plans/2026-02-04-feature-ideas-brainstorm.md`
|
||||
- **Tree Structure**: `backend/scripts/seed_trees.py` (examples)
|
||||
Reference in New Issue
Block a user