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 ? ( +
+ setNameInput(e.target.value)} + maxLength={100} + autoFocus + className="flex-1 px-2 py-1 text-lg font-bold bg-background border-b-2 border-primary focus:outline-none" + /> + + +
+ ) : ( + + )} +
+ +
+ {/* 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 && ( +
+
+ + setNewName(e.target.value)} + placeholder="e.g. Healthcare Watch" + maxLength={100} + className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + autoFocus + /> +
+ + {formError &&

{formError}

} +
+ + +
+
+ )} + + {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 */} +
+ + + PocketVeto + +
+ +
+ {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 */} +
+ + + PocketVeto + +
+ +
+ {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;