Files
resolutionflow/backend/app/api/endpoints/script_builder.py
chihlasm 64f004a62c feat: tenant isolation Phase 4 — RLS on 31 remaining tables + script_builder fix
Enable RLS on all remaining tenant-scoped tables (31 tables):

Standard policy (tenant sees own rows):
  users, account_invites, account_limit_overrides, account_feature_overrides,
  subscriptions, ai_chat_sessions, ai_conversations, ai_session_steps,
  ai_session_embeddings, ai_suggestions, ai_usage, assistant_chats,
  attachments, copilot_conversations, feedback, file_uploads, fork_points,
  kb_imports, notifications, notification_configs, notification_logs,
  psa_activity_logs, psa_member_mappings, script_builder_sessions,
  script_categories, session_ratings, tree_embeddings, user_folders,
  user_pinned_trees

Platform-visibility policy (own rows OR PLATFORM_ACCOUNT_ID):
  platform_steps, template_trees

Intentionally skipped:
  accounts (IS the root table, no account_id column)
  plan_feature_defaults (platform config, no account_id column)

Also fixes script_builder_service.create_session() which was missing
account_id= on ScriptBuilderSession construction, causing 500s on all
script builder endpoints (pre-existing CI failure).

Adds Phase 4 RLS isolation tests covering: users, script_builder_sessions,
ai_session_steps, notifications, platform_steps, template_trees.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:25:28 +00:00

199 lines
7.2 KiB
Python

"""Script Builder API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.script_builder_session import ScriptBuilderSession
from app.schemas.script_builder import (
ScriptBuilderCreateRequest,
ScriptBuilderMessageRequest,
ScriptBuilderMessageResponse,
ScriptBuilderMessageSchema,
ScriptBuilderSessionDetail,
ScriptBuilderSessionSummary,
SaveToLibraryRequest,
)
from app.schemas.script_template import ScriptTemplateDetail
from app.services import script_builder_service
router = APIRouter(prefix="/scripts/builder", tags=["script-builder"])
MAX_SESSIONS_PER_USER = 5
def _session_to_detail(session: ScriptBuilderSession) -> ScriptBuilderSessionDetail:
"""Convert a session ORM object (with message_records loaded) to detail schema."""
messages = [
ScriptBuilderMessageSchema.model_validate(m)
for m in session.message_records
]
return ScriptBuilderSessionDetail(
id=session.id,
language=session.language,
title=session.title,
messages=messages,
latest_script=session.latest_script,
latest_script_filename=session.latest_script_filename,
message_count=len([m for m in messages if m.role == "user"]),
ai_session_id=session.ai_session_id,
created_at=session.created_at,
updated_at=session.updated_at,
)
def _session_to_summary(session: ScriptBuilderSession) -> ScriptBuilderSessionSummary:
"""Convert a session ORM object to summary schema (no messages needed)."""
return ScriptBuilderSessionSummary(
id=session.id,
language=session.language,
title=session.title,
message_count=0, # Summary doesn't eagerly load messages
latest_script_filename=session.latest_script_filename,
created_at=session.created_at,
updated_at=session.updated_at,
)
@router.post("/sessions", response_model=ScriptBuilderSessionDetail, status_code=201)
async def create_session(
data: ScriptBuilderCreateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Start a new Script Builder session."""
# Acquire per-user advisory lock so concurrent create requests are serialized.
# Without this, two simultaneous requests both read count < limit and both
# insert, exceeding MAX_SESSIONS_PER_USER.
user_lock_key = hash(str(current_user.id)) % (2**62)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
# Enforce max concurrent sessions
count = await script_builder_service.count_user_sessions(db, current_user.id)
if count >= MAX_SESSIONS_PER_USER:
raise HTTPException(
status_code=400,
detail=f"Maximum of {MAX_SESSIONS_PER_USER} builder sessions allowed. Delete an old session first.",
)
session = await script_builder_service.create_session(
db=db,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
language=data.language,
)
await db.commit()
# Re-fetch with message_records loaded
session = await script_builder_service.get_session(db, session.id, current_user.id)
return _session_to_detail(session)
@router.get("/sessions", response_model=list[ScriptBuilderSessionSummary])
async def list_sessions(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
limit: int = 20,
offset: int = 0,
) -> list[ScriptBuilderSessionSummary]:
"""List user's recent builder sessions (lightweight, no messages)."""
sessions = await script_builder_service.list_sessions(
db=db, user_id=current_user.id, limit=limit, offset=offset
)
return [_session_to_summary(s) for s in sessions]
@router.get("/sessions/{session_id}", response_model=ScriptBuilderSessionDetail)
async def get_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Get full session detail with message history."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return _session_to_detail(session)
@router.post(
"/sessions/{session_id}/messages",
response_model=ScriptBuilderMessageResponse,
)
@limiter.limit("10/minute")
async def send_message(
request: Request,
session_id: UUID,
data: ScriptBuilderMessageRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderMessageResponse:
"""Send a message and get AI-generated script response."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
try:
response = await script_builder_service.send_message(db, session, data.content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await db.commit()
return response
@router.delete("/sessions/{session_id}", status_code=204)
async def delete_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
"""Delete a builder session."""
deleted = await script_builder_service.delete_session(db, session_id, current_user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Session not found")
await db.commit()
@router.post(
"/sessions/{session_id}/save",
response_model=ScriptTemplateDetail,
status_code=201,
)
async def save_to_library(
session_id: UUID,
data: SaveToLibraryRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptTemplateDetail:
"""Save the latest generated script to the Script Library."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
try:
template = await script_builder_service.save_to_library(
db=db,
session=session,
name=data.name,
description=data.description,
category_id=data.category_id,
share_with_team=data.share_with_team,
user_id=current_user.id,
team_id=current_user.team_id,
script_body=data.script_body,
parameters_schema=data.parameters_schema,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await db.commit()
await db.refresh(template)
return ScriptTemplateDetail.model_validate(template)