Phase 3 completion — Personal Workflow feature set is now complete.
Collections / Watchlists:
- New tables: collections (UUID share_token, slug, public/private) and
collection_bills (unique bill-per-collection constraint)
- Full CRUD API at /api/collections with bill add/remove endpoints
- Public share endpoint /api/collections/share/{token} (no auth)
- /collections list page with inline create form and delete
- /collections/[id] detail page: inline rename, public toggle,
copy-share-link, bill search/add/remove
- CollectionPicker bookmark-icon popover on bill detail pages
- Collections nav link in sidebar (auth-required)
Shareable Brief Links:
- share_token UUID column on bill_briefs (backfilled on migration)
- Unified public share router at /api/share (brief + collection)
- /share/brief/[token] — minimal layout, full AIBriefCard, CTAs
- /share/collection/[token] — minimal layout, bill list, CTA
- Share2 button in BriefPanel header row, "Link copied!" flash
AuthGuard: /collections → AUTH_REQUIRED; /share prefix → NO_SHELL_PATHS
Authored-By: Jack Levy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
"""
|
|
Public share router — no authentication required.
|
|
Serves shareable read-only views for briefs and collections.
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.database import get_db
|
|
from app.models.bill import Bill, BillDocument
|
|
from app.models.brief import BillBrief
|
|
from app.models.collection import Collection, CollectionBill
|
|
from app.schemas.schemas import (
|
|
BillSchema,
|
|
BriefSchema,
|
|
BriefShareResponse,
|
|
CollectionDetailSchema,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Brief share ───────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/brief/{token}", response_model=BriefShareResponse)
|
|
async def get_shared_brief(
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(BillBrief)
|
|
.options(
|
|
selectinload(BillBrief.bill).selectinload(Bill.sponsor),
|
|
selectinload(BillBrief.bill).selectinload(Bill.briefs),
|
|
selectinload(BillBrief.bill).selectinload(Bill.trend_scores),
|
|
)
|
|
.where(BillBrief.share_token == token)
|
|
)
|
|
brief = result.scalar_one_or_none()
|
|
if not brief:
|
|
raise HTTPException(status_code=404, detail="Brief not found")
|
|
|
|
bill = brief.bill
|
|
bill_schema = BillSchema.model_validate(bill)
|
|
if bill.briefs:
|
|
bill_schema.latest_brief = bill.briefs[0]
|
|
if bill.trend_scores:
|
|
bill_schema.latest_trend = bill.trend_scores[0]
|
|
|
|
doc_result = await db.execute(
|
|
select(BillDocument.bill_id).where(BillDocument.bill_id == bill.bill_id).limit(1)
|
|
)
|
|
bill_schema.has_document = doc_result.scalar_one_or_none() is not None
|
|
|
|
return BriefShareResponse(
|
|
brief=BriefSchema.model_validate(brief),
|
|
bill=bill_schema,
|
|
)
|
|
|
|
|
|
# ── Collection share ──────────────────────────────────────────────────────────
|
|
|
|
@router.get("/collection/{token}", response_model=CollectionDetailSchema)
|
|
async def get_shared_collection(
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(Collection)
|
|
.options(
|
|
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.briefs),
|
|
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.trend_scores),
|
|
selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.sponsor),
|
|
)
|
|
.where(Collection.share_token == token)
|
|
)
|
|
collection = result.scalar_one_or_none()
|
|
if not collection:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
|
|
cb_list = collection.collection_bills
|
|
bills = [cb.bill for cb in cb_list]
|
|
bill_ids = [b.bill_id for b in bills]
|
|
|
|
if bill_ids:
|
|
doc_result = await db.execute(
|
|
select(BillDocument.bill_id).where(BillDocument.bill_id.in_(bill_ids)).distinct()
|
|
)
|
|
bills_with_docs = {row[0] for row in doc_result}
|
|
else:
|
|
bills_with_docs = set()
|
|
|
|
bill_schemas = []
|
|
for bill in bills:
|
|
bs = BillSchema.model_validate(bill)
|
|
if bill.briefs:
|
|
bs.latest_brief = bill.briefs[0]
|
|
if bill.trend_scores:
|
|
bs.latest_trend = bill.trend_scores[0]
|
|
bs.has_document = bill.bill_id in bills_with_docs
|
|
bill_schemas.append(bs)
|
|
|
|
return CollectionDetailSchema(
|
|
id=collection.id,
|
|
name=collection.name,
|
|
slug=collection.slug,
|
|
is_public=collection.is_public,
|
|
share_token=collection.share_token,
|
|
bill_count=len(cb_list),
|
|
created_at=collection.created_at,
|
|
bills=bill_schemas,
|
|
)
|