From 9e5ac9b33d84cffbdf40556003deef6ced2fe3e0 Mon Sep 17 00:00:00 2001
From: Jack Levy
Date: Sun, 1 Mar 2026 23:23:45 -0500
Subject: [PATCH] feat: collections, watchlists, and shareable links (v0.9.0)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../alembic/versions/0015_add_collections.py | 52 +++
.../versions/0016_add_brief_share_token.py | 33 ++
backend/app/api/collections.py | 319 ++++++++++++++++++
backend/app/api/share.py | 113 +++++++
backend/app/main.py | 4 +-
backend/app/models/__init__.py | 3 +
backend/app/models/brief.py | 2 +
backend/app/models/collection.py | 51 +++
backend/app/models/user.py | 1 +
backend/app/schemas/schemas.py | 40 +++
frontend/app/bills/[id]/page.tsx | 6 +-
frontend/app/collections/[id]/page.tsx | 252 ++++++++++++++
frontend/app/collections/page.tsx | 163 +++++++++
frontend/app/share/brief/[token]/page.tsx | 78 +++++
.../app/share/collection/[token]/page.tsx | 94 ++++++
frontend/components/bills/BriefPanel.tsx | 27 +-
.../components/bills/CollectionPicker.tsx | 143 ++++++++
frontend/components/shared/AuthGuard.tsx | 8 +-
frontend/components/shared/Sidebar.tsx | 2 +
frontend/lib/api.ts | 30 ++
frontend/lib/types.ts | 15 +
21 files changed, 1429 insertions(+), 7 deletions(-)
create mode 100644 backend/alembic/versions/0015_add_collections.py
create mode 100644 backend/alembic/versions/0016_add_brief_share_token.py
create mode 100644 backend/app/api/collections.py
create mode 100644 backend/app/api/share.py
create mode 100644 backend/app/models/collection.py
create mode 100644 frontend/app/collections/[id]/page.tsx
create mode 100644 frontend/app/collections/page.tsx
create mode 100644 frontend/app/share/brief/[token]/page.tsx
create mode 100644 frontend/app/share/collection/[token]/page.tsx
create mode 100644 frontend/components/bills/CollectionPicker.tsx
diff --git a/backend/alembic/versions/0015_add_collections.py b/backend/alembic/versions/0015_add_collections.py
new file mode 100644
index 0000000..6a6f37d
--- /dev/null
+++ b/backend/alembic/versions/0015_add_collections.py
@@ -0,0 +1,52 @@
+"""Add collections and collection_bills tables
+
+Revision ID: 0015
+Revises: 0014
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = "0015"
+down_revision = "0014"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "collections",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("name", sa.String(100), nullable=False),
+ sa.Column("slug", sa.String(120), nullable=False),
+ sa.Column("is_public", sa.Boolean(), nullable=False, server_default="false"),
+ sa.Column(
+ "share_token",
+ postgresql.UUID(as_uuid=False),
+ nullable=False,
+ server_default=sa.text("gen_random_uuid()"),
+ ),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.UniqueConstraint("share_token", name="uq_collections_share_token"),
+ sa.UniqueConstraint("user_id", "slug", name="uq_collections_user_slug"),
+ )
+ op.create_index("ix_collections_user_id", "collections", ["user_id"])
+ op.create_index("ix_collections_share_token", "collections", ["share_token"])
+
+ op.create_table(
+ "collection_bills",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("collection_id", sa.Integer(), sa.ForeignKey("collections.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("bill_id", sa.String(), sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
+ sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.UniqueConstraint("collection_id", "bill_id", name="uq_collection_bills_collection_bill"),
+ )
+ op.create_index("ix_collection_bills_collection_id", "collection_bills", ["collection_id"])
+ op.create_index("ix_collection_bills_bill_id", "collection_bills", ["bill_id"])
+
+
+def downgrade():
+ op.drop_table("collection_bills")
+ op.drop_table("collections")
diff --git a/backend/alembic/versions/0016_add_brief_share_token.py b/backend/alembic/versions/0016_add_brief_share_token.py
new file mode 100644
index 0000000..1684bfa
--- /dev/null
+++ b/backend/alembic/versions/0016_add_brief_share_token.py
@@ -0,0 +1,33 @@
+"""Add share_token to bill_briefs
+
+Revision ID: 0016
+Revises: 0015
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision = "0016"
+down_revision = "0015"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column(
+ "bill_briefs",
+ sa.Column(
+ "share_token",
+ postgresql.UUID(as_uuid=False),
+ nullable=True,
+ server_default=sa.text("gen_random_uuid()"),
+ ),
+ )
+ op.create_unique_constraint("uq_brief_share_token", "bill_briefs", ["share_token"])
+ op.create_index("ix_brief_share_token", "bill_briefs", ["share_token"])
+
+
+def downgrade():
+ op.drop_index("ix_brief_share_token", "bill_briefs")
+ op.drop_constraint("uq_brief_share_token", "bill_briefs")
+ op.drop_column("bill_briefs", "share_token")
diff --git a/backend/app/api/collections.py b/backend/app/api/collections.py
new file mode 100644
index 0000000..8b006c0
--- /dev/null
+++ b/backend/app/api/collections.py
@@ -0,0 +1,319 @@
+"""
+Collections API — named, curated groups of bills with share links.
+"""
+import re
+import unicodedata
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.core.dependencies import get_current_user
+from app.database import get_db
+from app.models.bill import Bill, BillDocument
+from app.models.collection import Collection, CollectionBill
+from app.models.user import User
+from app.schemas.schemas import (
+ BillSchema,
+ CollectionCreate,
+ CollectionDetailSchema,
+ CollectionSchema,
+ CollectionUpdate,
+)
+
+router = APIRouter()
+
+
+def _slugify(text: str) -> str:
+ text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
+ text = re.sub(r"[^\w\s-]", "", text.lower())
+ return re.sub(r"[-\s]+", "-", text).strip("-")
+
+
+async def _unique_slug(db: AsyncSession, user_id: int, name: str, exclude_id: int | None = None) -> str:
+ base = _slugify(name) or "collection"
+ slug = base
+ counter = 2
+ while True:
+ q = select(Collection).where(Collection.user_id == user_id, Collection.slug == slug)
+ if exclude_id is not None:
+ q = q.where(Collection.id != exclude_id)
+ existing = (await db.execute(q)).scalar_one_or_none()
+ if not existing:
+ return slug
+ slug = f"{base}-{counter}"
+ counter += 1
+
+
+def _to_schema(collection: Collection) -> CollectionSchema:
+ return CollectionSchema(
+ id=collection.id,
+ name=collection.name,
+ slug=collection.slug,
+ is_public=collection.is_public,
+ share_token=collection.share_token,
+ bill_count=len(collection.collection_bills),
+ created_at=collection.created_at,
+ )
+
+
+async def _detail_schema(db: AsyncSession, collection: Collection) -> CollectionDetailSchema:
+ """Build CollectionDetailSchema with bills (including has_document)."""
+ 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,
+ )
+
+
+async def _load_collection(db: AsyncSession, collection_id: int) -> Collection:
+ 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.id == collection_id)
+ )
+ return result.scalar_one_or_none()
+
+
+# ── List ──────────────────────────────────────────────────────────────────────
+
+@router.get("", response_model=list[CollectionSchema])
+async def list_collections(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Collection)
+ .options(selectinload(Collection.collection_bills))
+ .where(Collection.user_id == current_user.id)
+ .order_by(Collection.created_at.desc())
+ )
+ collections = result.scalars().unique().all()
+ return [_to_schema(c) for c in collections]
+
+
+# ── Create ────────────────────────────────────────────────────────────────────
+
+@router.post("", response_model=CollectionSchema, status_code=201)
+async def create_collection(
+ body: CollectionCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ name = body.name.strip()
+ if not 1 <= len(name) <= 100:
+ raise HTTPException(status_code=422, detail="name must be 1–100 characters")
+
+ slug = await _unique_slug(db, current_user.id, name)
+ collection = Collection(
+ user_id=current_user.id,
+ name=name,
+ slug=slug,
+ is_public=body.is_public,
+ )
+ db.add(collection)
+ await db.flush()
+ await db.execute(select(Collection).where(Collection.id == collection.id)) # ensure loaded
+ await db.commit()
+ await db.refresh(collection)
+
+ # Load collection_bills for bill_count
+ result = await db.execute(
+ select(Collection)
+ .options(selectinload(Collection.collection_bills))
+ .where(Collection.id == collection.id)
+ )
+ collection = result.scalar_one()
+ return _to_schema(collection)
+
+
+# ── Share (public — no auth) ──────────────────────────────────────────────────
+
+@router.get("/share/{share_token}", response_model=CollectionDetailSchema)
+async def get_collection_by_share_token(
+ share_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 == share_token)
+ )
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ return await _detail_schema(db, collection)
+
+
+# ── Get (owner) ───────────────────────────────────────────────────────────────
+
+@router.get("/{collection_id}", response_model=CollectionDetailSchema)
+async def get_collection(
+ collection_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ collection = await _load_collection(db, collection_id)
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ if collection.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ return await _detail_schema(db, collection)
+
+
+# ── Update ────────────────────────────────────────────────────────────────────
+
+@router.patch("/{collection_id}", response_model=CollectionSchema)
+async def update_collection(
+ collection_id: int,
+ body: CollectionUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Collection)
+ .options(selectinload(Collection.collection_bills))
+ .where(Collection.id == collection_id)
+ )
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ if collection.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ if body.name is not None:
+ name = body.name.strip()
+ if not 1 <= len(name) <= 100:
+ raise HTTPException(status_code=422, detail="name must be 1–100 characters")
+ collection.name = name
+ collection.slug = await _unique_slug(db, current_user.id, name, exclude_id=collection_id)
+
+ if body.is_public is not None:
+ collection.is_public = body.is_public
+
+ await db.commit()
+ await db.refresh(collection)
+
+ result = await db.execute(
+ select(Collection)
+ .options(selectinload(Collection.collection_bills))
+ .where(Collection.id == collection_id)
+ )
+ collection = result.scalar_one()
+ return _to_schema(collection)
+
+
+# ── Delete ────────────────────────────────────────────────────────────────────
+
+@router.delete("/{collection_id}", status_code=204)
+async def delete_collection(
+ collection_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(Collection).where(Collection.id == collection_id))
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ if collection.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ await db.delete(collection)
+ await db.commit()
+
+
+# ── Add bill ──────────────────────────────────────────────────────────────────
+
+@router.post("/{collection_id}/bills/{bill_id}", status_code=204)
+async def add_bill_to_collection(
+ collection_id: int,
+ bill_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(Collection).where(Collection.id == collection_id))
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ if collection.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ bill = await db.get(Bill, bill_id)
+ if not bill:
+ raise HTTPException(status_code=404, detail="Bill not found")
+
+ existing = await db.execute(
+ select(CollectionBill).where(
+ CollectionBill.collection_id == collection_id,
+ CollectionBill.bill_id == bill_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ return # idempotent
+
+ db.add(CollectionBill(collection_id=collection_id, bill_id=bill_id))
+ await db.commit()
+
+
+# ── Remove bill ───────────────────────────────────────────────────────────────
+
+@router.delete("/{collection_id}/bills/{bill_id}", status_code=204)
+async def remove_bill_from_collection(
+ collection_id: int,
+ bill_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(Collection).where(Collection.id == collection_id))
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=404, detail="Collection not found")
+ if collection.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ cb_result = await db.execute(
+ select(CollectionBill).where(
+ CollectionBill.collection_id == collection_id,
+ CollectionBill.bill_id == bill_id,
+ )
+ )
+ cb = cb_result.scalar_one_or_none()
+ if not cb:
+ raise HTTPException(status_code=404, detail="Bill not in collection")
+ await db.delete(cb)
+ await db.commit()
diff --git a/backend/app/api/share.py b/backend/app/api/share.py
new file mode 100644
index 0000000..eadf4b6
--- /dev/null
+++ b/backend/app/api/share.py
@@ -0,0 +1,113 @@
+"""
+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,
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index 0d68670..f8dec42 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes
+from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes, collections, share
from app.config import settings as config
app = FastAPI(
@@ -29,3 +29,5 @@ app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(health.router, prefix="/api/health", tags=["health"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
app.include_router(notes.router, prefix="/api/notes", tags=["notes"])
+app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
+app.include_router(share.router, prefix="/api/share", tags=["share"])
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 5fb031b..9370551 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -1,5 +1,6 @@
from app.models.bill import Bill, BillAction, BillDocument
from app.models.brief import BillBrief
+from app.models.collection import Collection, CollectionBill
from app.models.follow import Follow
from app.models.member import Member
from app.models.member_interest import MemberTrendScore, MemberNewsArticle
@@ -17,6 +18,8 @@ __all__ = [
"BillDocument",
"BillBrief",
"BillNote",
+ "Collection",
+ "CollectionBill",
"Follow",
"Member",
"MemberTrendScore",
diff --git a/backend/app/models/brief.py b/backend/app/models/brief.py
index 5d28321..d032edd 100644
--- a/backend/app/models/brief.py
+++ b/backend/app/models/brief.py
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Index
+from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -21,6 +22,7 @@ class BillBrief(Base):
llm_provider = Column(String(50))
llm_model = Column(String(100))
govinfo_url = Column(String, nullable=True)
+ share_token = Column(postgresql.UUID(as_uuid=False), nullable=True, server_default=func.gen_random_uuid())
created_at = Column(DateTime(timezone=True), server_default=func.now())
bill = relationship("Bill", back_populates="briefs")
diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py
new file mode 100644
index 0000000..02c4423
--- /dev/null
+++ b/backend/app/models/collection.py
@@ -0,0 +1,51 @@
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+
+from app.database import Base
+
+
+class Collection(Base):
+ __tablename__ = "collections"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
+ name = Column(String(100), nullable=False)
+ slug = Column(String(120), nullable=False)
+ is_public = Column(Boolean, nullable=False, default=False, server_default="false")
+ share_token = Column(UUID(as_uuid=False), nullable=False, server_default=func.gen_random_uuid())
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+ user = relationship("User", back_populates="collections")
+ collection_bills = relationship(
+ "CollectionBill",
+ cascade="all, delete-orphan",
+ order_by="CollectionBill.added_at.desc()",
+ )
+
+ __table_args__ = (
+ UniqueConstraint("user_id", "slug", name="uq_collections_user_slug"),
+ UniqueConstraint("share_token", name="uq_collections_share_token"),
+ Index("ix_collections_user_id", "user_id"),
+ Index("ix_collections_share_token", "share_token"),
+ )
+
+
+class CollectionBill(Base):
+ __tablename__ = "collection_bills"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ collection_id = Column(Integer, ForeignKey("collections.id", ondelete="CASCADE"), nullable=False)
+ bill_id = Column(String, ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False)
+ added_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ collection = relationship("Collection", back_populates="collection_bills")
+ bill = relationship("Bill")
+
+ __table_args__ = (
+ UniqueConstraint("collection_id", "bill_id", name="uq_collection_bills_collection_bill"),
+ Index("ix_collection_bills_collection_id", "collection_id"),
+ Index("ix_collection_bills_bill_id", "bill_id"),
+ )
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 9603977..9e57d00 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -20,3 +20,4 @@ class User(Base):
follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan")
notification_events = relationship("NotificationEvent", back_populates="user", cascade="all, delete-orphan")
bill_notes = relationship("BillNote", back_populates="user", cascade="all, delete-orphan")
+ collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index 7622ed7..4995540 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -140,6 +140,7 @@ class BriefSchema(BaseModel):
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
govinfo_url: Optional[str] = None
+ share_token: Optional[str] = None
created_at: Optional[datetime] = None
model_config = {"from_attributes": True}
@@ -295,3 +296,42 @@ class SettingsResponse(BaseModel):
congress_poll_interval_minutes: int
newsapi_enabled: bool
pytrends_enabled: bool
+
+
+# ── Collections ────────────────────────────────────────────────────────────────
+
+class CollectionCreate(BaseModel):
+ name: str
+ is_public: bool = False
+
+ def validate_name(self) -> str:
+ name = self.name.strip()
+ if not 1 <= len(name) <= 100:
+ raise ValueError("name must be 1–100 characters")
+ return name
+
+
+class CollectionUpdate(BaseModel):
+ name: Optional[str] = None
+ is_public: Optional[bool] = None
+
+
+class CollectionSchema(BaseModel):
+ id: int
+ name: str
+ slug: str
+ is_public: bool
+ share_token: str
+ bill_count: int
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class CollectionDetailSchema(CollectionSchema):
+ bills: list[BillSchema]
+
+
+class BriefShareResponse(BaseModel):
+ brief: BriefSchema
+ bill: BillSchema
diff --git a/frontend/app/bills/[id]/page.tsx b/frontend/app/bills/[id]/page.tsx
index 7616b56..c9eec91 100644
--- a/frontend/app/bills/[id]/page.tsx
+++ b/frontend/app/bills/[id]/page.tsx
@@ -12,6 +12,7 @@ import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
+import { CollectionPicker } from "@/components/bills/CollectionPicker";
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
@@ -109,7 +110,10 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
)}
-
+
+
+
+
{/* Content grid */}
diff --git a/frontend/app/collections/[id]/page.tsx b/frontend/app/collections/[id]/page.tsx
new file mode 100644
index 0000000..3a7289b
--- /dev/null
+++ b/frontend/app/collections/[id]/page.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+import { use, useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+import { ArrowLeft, Check, Copy, Globe, Lock, Minus, Search, X } from "lucide-react";
+import { collectionsAPI, billsAPI } from "@/lib/api";
+import type { Bill } from "@/lib/types";
+import { billLabel, formatDate } from "@/lib/utils";
+
+export default function CollectionDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = use(params);
+ const collectionId = parseInt(id, 10);
+ const qc = useQueryClient();
+
+ const [editingName, setEditingName] = useState(false);
+ const [nameInput, setNameInput] = useState("");
+ const [copied, setCopied] = useState(false);
+ const [searchQ, setSearchQ] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [searching, setSearching] = useState(false);
+
+ const { data: collection, isLoading } = useQuery({
+ queryKey: ["collection", collectionId],
+ queryFn: () => collectionsAPI.get(collectionId),
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: (data: { name?: string; is_public?: boolean }) =>
+ collectionsAPI.update(collectionId, data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["collection", collectionId] });
+ qc.invalidateQueries({ queryKey: ["collections"] });
+ setEditingName(false);
+ },
+ });
+
+ const addBillMutation = useMutation({
+ mutationFn: (bill_id: string) => collectionsAPI.addBill(collectionId, bill_id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["collection", collectionId] });
+ qc.invalidateQueries({ queryKey: ["collections"] });
+ },
+ });
+
+ const removeBillMutation = useMutation({
+ mutationFn: (bill_id: string) => collectionsAPI.removeBill(collectionId, bill_id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["collection", collectionId] });
+ qc.invalidateQueries({ queryKey: ["collections"] });
+ },
+ });
+
+ async function handleSearch(q: string) {
+ setSearchQ(q);
+ if (!q.trim()) { setSearchResults([]); return; }
+ setSearching(true);
+ try {
+ const res = await billsAPI.list({ q, per_page: 8 });
+ setSearchResults(res.items);
+ } finally {
+ setSearching(false);
+ }
+ }
+
+ function copyShareLink() {
+ if (!collection) return;
+ navigator.clipboard.writeText(`${window.location.origin}/share/collection/${collection.share_token}`);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+
+ function startRename() {
+ setNameInput(collection?.name ?? "");
+ setEditingName(true);
+ }
+
+ function submitRename(e: React.FormEvent) {
+ e.preventDefault();
+ const name = nameInput.trim();
+ if (!name || name === collection?.name) { setEditingName(false); return; }
+ updateMutation.mutate({ name });
+ }
+
+ if (isLoading) {
+ return Loading…
;
+ }
+ if (!collection) {
+ return (
+
+
Collection not found.
+
← Back to collections
+
+ );
+ }
+
+ const collectionBillIds = new Set(collection.bills.map((b) => b.bill_id));
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {editingName ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Public/private toggle */}
+
+
+ {/* Copy share link */}
+
+
+
+ {collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
+
+
+
+
+ {/* Add bills search */}
+
+
+
+ handleSearch(e.target.value)}
+ placeholder="Search to add bills…"
+ className="flex-1 text-sm bg-transparent focus:outline-none"
+ />
+ {searching && Searching…}
+
+ {searchResults.length > 0 && searchQ && (
+
+ {searchResults.map((bill) => {
+ const inCollection = collectionBillIds.has(bill.bill_id);
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {/* Bill list */}
+ {collection.bills.length === 0 ? (
+
+
No bills yet — search to add some.
+
+ ) : (
+
+ {collection.bills.map((bill) => (
+
+
+
+
+ {billLabel(bill.bill_type, bill.bill_number)}
+
+
+ {bill.short_title || bill.title || "Untitled"}
+
+
+ {bill.latest_action_date && (
+
+ Latest action: {formatDate(bill.latest_action_date)}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/collections/page.tsx b/frontend/app/collections/page.tsx
new file mode 100644
index 0000000..7663192
--- /dev/null
+++ b/frontend/app/collections/page.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react";
+import { collectionsAPI } from "@/lib/api";
+import type { Collection } from "@/lib/types";
+
+export default function CollectionsPage() {
+ const qc = useQueryClient();
+ const [showForm, setShowForm] = useState(false);
+ const [newName, setNewName] = useState("");
+ const [newPublic, setNewPublic] = useState(false);
+ const [formError, setFormError] = useState("");
+
+ const { data: collections, isLoading } = useQuery({
+ queryKey: ["collections"],
+ queryFn: collectionsAPI.list,
+ });
+
+ const createMutation = useMutation({
+ mutationFn: ({ name, is_public }: { name: string; is_public: boolean }) =>
+ collectionsAPI.create(name, is_public),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["collections"] });
+ setNewName("");
+ setNewPublic(false);
+ setShowForm(false);
+ setFormError("");
+ },
+ onError: () => setFormError("Failed to create collection. Try again."),
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: number) => collectionsAPI.delete(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["collections"] }),
+ });
+
+ function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ const name = newName.trim();
+ if (!name) { setFormError("Name is required"); return; }
+ if (name.length > 100) { setFormError("Name must be ≤ 100 characters"); return; }
+ setFormError("");
+ createMutation.mutate({ name, is_public: newPublic });
+ }
+
+ return (
+
+
+
+
+
My Collections
+
+
+
+
+ {showForm && (
+
+ )}
+
+ {isLoading ? (
+
Loading collections…
+ ) : !collections || collections.length === 0 ? (
+
+
+
No collections yet — create one to start grouping bills.
+
+ ) : (
+
+ {collections.map((c: Collection) => (
+
+
+
+ {c.name}
+
+ {c.bill_count} {c.bill_count === 1 ? "bill" : "bills"}
+
+ {c.is_public ? (
+
+ Public
+
+ ) : (
+
+ Private
+
+ )}
+
+
+ Created {new Date(c.created_at).toLocaleDateString()}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/share/brief/[token]/page.tsx b/frontend/app/share/brief/[token]/page.tsx
new file mode 100644
index 0000000..24cdedd
--- /dev/null
+++ b/frontend/app/share/brief/[token]/page.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { use } from "react";
+import { useQuery } from "@tanstack/react-query";
+import Link from "next/link";
+import { ExternalLink, Landmark } from "lucide-react";
+import { shareAPI } from "@/lib/api";
+import { AIBriefCard } from "@/components/bills/AIBriefCard";
+import { billLabel } from "@/lib/utils";
+
+export default function SharedBriefPage({ params }: { params: Promise<{ token: string }> }) {
+ const { token } = use(params);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ["share-brief", token],
+ queryFn: () => shareAPI.getBrief(token),
+ retry: false,
+ });
+
+ return (
+
+ {/* Minimal header */}
+
+
+
+ {isLoading && (
+
Loading…
+ )}
+
+ {isError && (
+
+
Brief not found or link is invalid.
+
+ )}
+
+ {data && (
+ <>
+ {/* Bill label + title */}
+
+
+
+ {billLabel(data.bill.bill_type, data.bill.bill_number)}
+
+
+
+ {data.bill.short_title || data.bill.title || "Untitled Bill"}
+
+
+
+ {/* Full brief */}
+
+
+ {/* CTAs */}
+
+
+ View full bill page
+
+
+ Track this bill on PocketVeto →
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/app/share/collection/[token]/page.tsx b/frontend/app/share/collection/[token]/page.tsx
new file mode 100644
index 0000000..f7bd1aa
--- /dev/null
+++ b/frontend/app/share/collection/[token]/page.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { use } from "react";
+import { useQuery } from "@tanstack/react-query";
+import Link from "next/link";
+import { Landmark } from "lucide-react";
+import { shareAPI } from "@/lib/api";
+import type { Bill } from "@/lib/types";
+import { billLabel, formatDate } from "@/lib/utils";
+
+export default function SharedCollectionPage({ params }: { params: Promise<{ token: string }> }) {
+ const { token } = use(params);
+
+ const { data: collection, isLoading, isError } = useQuery({
+ queryKey: ["share-collection", token],
+ queryFn: () => shareAPI.getCollection(token),
+ retry: false,
+ });
+
+ return (
+
+ {/* Minimal header */}
+
+
+
+ {isLoading && (
+
Loading…
+ )}
+
+ {isError && (
+
+
Collection not found or link is invalid.
+
+ )}
+
+ {collection && (
+ <>
+ {/* Header */}
+
+
{collection.name}
+
+ {collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
+
+
+
+ {/* Bill list */}
+ {collection.bills.length === 0 ? (
+
No bills in this collection.
+ ) : (
+
+ {collection.bills.map((bill: Bill) => (
+
+
+
+ {billLabel(bill.bill_type, bill.bill_number)}
+
+
+ {bill.short_title || bill.title || "Untitled"}
+
+
+ {bill.latest_action_date && (
+
+ Latest action: {formatDate(bill.latest_action_date)}
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* CTA */}
+
+
+ Follow these bills on PocketVeto →
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/components/bills/BriefPanel.tsx b/frontend/components/bills/BriefPanel.tsx
index 5c4888c..6fe102b 100644
--- a/frontend/components/bills/BriefPanel.tsx
+++ b/frontend/components/bills/BriefPanel.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
+import { Check, ChevronDown, ChevronRight, RefreshCw, Share2 } from "lucide-react";
import { BriefSchema } from "@/lib/types";
import { AIBriefCard } from "@/components/bills/AIBriefCard";
import { formatDate } from "@/lib/utils";
@@ -34,6 +34,14 @@ function typeBadge(briefType?: string) {
export function BriefPanel({ briefs }: BriefPanelProps) {
const [historyOpen, setHistoryOpen] = useState(false);
const [expandedId, setExpandedId] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ function copyShareLink(brief: BriefSchema) {
+ if (!brief.share_token) return;
+ navigator.clipboard.writeText(`${window.location.origin}/share/brief/${brief.share_token}`);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
if (!briefs || briefs.length === 0) {
return ;
@@ -57,6 +65,23 @@ export function BriefPanel({ briefs }: BriefPanelProps) {
)}
+ {/* Share button row */}
+ {latest.share_token && (
+
+
+
+ )}
+
{/* Latest brief */}
diff --git a/frontend/components/bills/CollectionPicker.tsx b/frontend/components/bills/CollectionPicker.tsx
new file mode 100644
index 0000000..4fb0a0b
--- /dev/null
+++ b/frontend/components/bills/CollectionPicker.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+import { Bookmark, Check } from "lucide-react";
+import { collectionsAPI } from "@/lib/api";
+import { useAuthStore } from "@/stores/authStore";
+import type { Collection } from "@/lib/types";
+
+interface CollectionPickerProps {
+ billId: string;
+}
+
+export function CollectionPicker({ billId }: CollectionPickerProps) {
+ const token = useAuthStore((s) => s.token);
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const qc = useQueryClient();
+
+ useEffect(() => {
+ if (!open) return;
+ function onClickOutside(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", onClickOutside);
+ return () => document.removeEventListener("mousedown", onClickOutside);
+ }, [open]);
+
+ const { data: collections } = useQuery({
+ queryKey: ["collections"],
+ queryFn: collectionsAPI.list,
+ enabled: !!token,
+ });
+
+ const addMutation = useMutation({
+ mutationFn: (id: number) => collectionsAPI.addBill(id, billId),
+ onSuccess: (_, id) => {
+ qc.invalidateQueries({ queryKey: ["collections"] });
+ qc.invalidateQueries({ queryKey: ["collection", id] });
+ },
+ });
+
+ const removeMutation = useMutation({
+ mutationFn: (id: number) => collectionsAPI.removeBill(id, billId),
+ onSuccess: (_, id) => {
+ qc.invalidateQueries({ queryKey: ["collections"] });
+ qc.invalidateQueries({ queryKey: ["collection", id] });
+ },
+ });
+
+ if (!token) return null;
+
+ // Determine which collections contain this bill
+ // We check each collection's bill_count proxy by re-fetching detail... but since the list
+ // endpoint doesn't return bill_ids, we use a lightweight approach: track via optimistic state.
+ // The collection detail page has the bill list; for the picker we just check each collection.
+ // To avoid N+1, we'll use a separate query to get the user's collection memberships for this bill.
+ // For simplicity, we use the collections list and compare via a bill-membership query.
+
+ return (
+
+
+
+ {open && (
+
+ {!collections || collections.length === 0 ? (
+
+ No collections yet.
+
+ ) : (
+
+ {collections.map((c: Collection) => (
+ addMutation.mutate(c.id)}
+ onRemove={() => removeMutation.mutate(c.id)}
+ />
+ ))}
+
+ )}
+
+ setOpen(false)}
+ className="text-xs text-primary hover:underline"
+ >
+ New collection →
+
+
+
+ )}
+
+ );
+}
+
+function CollectionPickerRow({
+ collection,
+ billId,
+ onAdd,
+ onRemove,
+}: {
+ collection: Collection;
+ billId: string;
+ onAdd: () => void;
+ onRemove: () => void;
+}) {
+ // Fetch detail to know if this bill is in the collection
+ const { data: detail } = useQuery({
+ queryKey: ["collection", collection.id],
+ queryFn: () => collectionsAPI.get(collection.id),
+ });
+
+ const inCollection = detail?.bills.some((b) => b.bill_id === billId) ?? false;
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/components/shared/AuthGuard.tsx b/frontend/components/shared/AuthGuard.tsx
index 4698de9..bc4ca68 100644
--- a/frontend/components/shared/AuthGuard.tsx
+++ b/frontend/components/shared/AuthGuard.tsx
@@ -6,8 +6,8 @@ import { useAuthStore } from "@/stores/authStore";
import { Sidebar } from "./Sidebar";
import { MobileHeader } from "./MobileHeader";
-const NO_SHELL_PATHS = ["/login", "/register"];
-const AUTH_REQUIRED = ["/following", "/notifications"];
+const NO_SHELL_PATHS = ["/login", "/register", "/share"];
+const AUTH_REQUIRED = ["/following", "/notifications", "/collections"];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -31,8 +31,8 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
if (!hydrated) return null;
- // Login/register pages render without the app shell
- if (NO_SHELL_PATHS.includes(pathname)) {
+ // Login/register/share pages render without the app shell
+ if (NO_SHELL_PATHS.some((p) => pathname.startsWith(p))) {
return <>{children}>;
}
diff --git a/frontend/components/shared/Sidebar.tsx b/frontend/components/shared/Sidebar.tsx
index f59c107..950d455 100644
--- a/frontend/components/shared/Sidebar.tsx
+++ b/frontend/components/shared/Sidebar.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
+ Bookmark,
LayoutDashboard,
FileText,
Users,
@@ -25,6 +26,7 @@ const NAV = [
{ href: "/members", label: "Members", icon: Users, adminOnly: false, requiresAuth: false },
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false, requiresAuth: false },
{ href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
+ { href: "/collections", label: "Collections", icon: Bookmark, adminOnly: false, requiresAuth: true },
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
];
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 0b29c4a..e2fb638 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -3,6 +3,9 @@ import type {
Bill,
BillAction,
BillDetail,
+ BriefSchema,
+ Collection,
+ CollectionDetail,
DashboardData,
Follow,
Member,
@@ -86,6 +89,33 @@ export const billsAPI = {
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
};
+// Collections
+export const collectionsAPI = {
+ list: () =>
+ apiClient.get("/api/collections").then((r) => r.data),
+ create: (name: string, is_public: boolean) =>
+ apiClient.post("/api/collections", { name, is_public }).then((r) => r.data),
+ get: (id: number) =>
+ apiClient.get(`/api/collections/${id}`).then((r) => r.data),
+ update: (id: number, data: { name?: string; is_public?: boolean }) =>
+ apiClient.patch(`/api/collections/${id}`, data).then((r) => r.data),
+ delete: (id: number) => apiClient.delete(`/api/collections/${id}`),
+ addBill: (id: number, bill_id: string) =>
+ apiClient.post(`/api/collections/${id}/bills/${bill_id}`).then((r) => r.data),
+ removeBill: (id: number, bill_id: string) =>
+ apiClient.delete(`/api/collections/${id}/bills/${bill_id}`),
+ getByShareToken: (token: string) =>
+ apiClient.get(`/api/collections/share/${token}`).then((r) => r.data),
+};
+
+// Share (public)
+export const shareAPI = {
+ getBrief: (token: string) =>
+ apiClient.get<{ brief: BriefSchema; bill: Bill }>(`/api/share/brief/${token}`).then((r) => r.data),
+ getCollection: (token: string) =>
+ apiClient.get(`/api/share/collection/${token}`).then((r) => r.data),
+};
+
// Notes
export const notesAPI = {
get: (billId: string) =>
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 591f73f..22948a9 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -72,6 +72,7 @@ export interface BriefSchema {
llm_provider?: string;
llm_model?: string;
govinfo_url?: string;
+ share_token?: string;
created_at?: string;
}
@@ -183,6 +184,20 @@ export interface NotificationSettings {
timezone: string | null; // IANA name, e.g. "America/New_York"
}
+export interface Collection {
+ id: number;
+ name: string;
+ slug: string;
+ is_public: boolean;
+ share_token: string;
+ bill_count: number;
+ created_at: string;
+}
+
+export interface CollectionDetail extends Collection {
+ bills: Bill[];
+}
+
export interface NotificationEvent {
id: number;
bill_id: string;