Merge feat/phase3-complete: collections, watchlists & shareable links (v0.9.0)
Authored-By: Jack Levy
This commit is contained in:
52
backend/alembic/versions/0015_add_collections.py
Normal file
52
backend/alembic/versions/0015_add_collections.py
Normal file
@@ -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")
|
||||
33
backend/alembic/versions/0016_add_brief_share_token.py
Normal file
33
backend/alembic/versions/0016_add_brief_share_token.py
Normal file
@@ -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")
|
||||
319
backend/app/api/collections.py
Normal file
319
backend/app/api/collections.py
Normal file
@@ -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()
|
||||
113
backend/app/api/share.py
Normal file
113
backend/app/api/share.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
51
backend/app/models/collection.py
Normal file
51
backend/app/models/collection.py
Normal file
@@ -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"),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<FollowButton type="bill" value={bill.bill_id} supportsModes />
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<CollectionPicker billId={bill.bill_id} />
|
||||
<FollowButton type="bill" value={bill.bill_id} supportsModes />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content grid */}
|
||||
|
||||
252
frontend/app/collections/[id]/page.tsx
Normal file
252
frontend/app/collections/[id]/page.tsx
Normal file
@@ -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<Bill[]>([]);
|
||||
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 <div className="text-center py-20 text-muted-foreground text-sm">Loading…</div>;
|
||||
}
|
||||
if (!collection) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Collection not found.</p>
|
||||
<Link href="/collections" className="text-sm text-primary mt-2 inline-block">← Back to collections</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const collectionBillIds = new Set(collection.bills.map((b) => b.bill_id));
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/collections" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
{editingName ? (
|
||||
<form onSubmit={submitRename} className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button type="submit" className="p-1 text-primary hover:opacity-70">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditingName(false)} className="p-1 text-muted-foreground hover:opacity-70">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={startRename}
|
||||
className="text-xl font-bold hover:opacity-70 transition-opacity text-left"
|
||||
title="Click to rename"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Public/private toggle */}
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ is_public: !collection.is_public })}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
{collection.is_public ? (
|
||||
<><Globe className="w-3 h-3 text-green-500" /> Public</>
|
||||
) : (
|
||||
<><Lock className="w-3 h-3 text-muted-foreground" /> Private</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy share link */}
|
||||
<button
|
||||
onClick={copyShareLink}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<><Check className="w-3 h-3 text-green-500" /> Link copied!</>
|
||||
) : (
|
||||
<><Copy className="w-3 h-3" /> Copy share link</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add bills search */}
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-card border border-border rounded-lg">
|
||||
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQ}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search to add bills…"
|
||||
className="flex-1 text-sm bg-transparent focus:outline-none"
|
||||
/>
|
||||
{searching && <span className="text-xs text-muted-foreground">Searching…</span>}
|
||||
</div>
|
||||
{searchResults.length > 0 && searchQ && (
|
||||
<div className="absolute top-full left-0 right-0 z-10 mt-1 bg-card border border-border rounded-lg shadow-lg overflow-hidden">
|
||||
{searchResults.map((bill) => {
|
||||
const inCollection = collectionBillIds.has(bill.bill_id);
|
||||
return (
|
||||
<button
|
||||
key={bill.bill_id}
|
||||
onClick={() => {
|
||||
if (!inCollection) {
|
||||
addBillMutation.mutate(bill.bill_id);
|
||||
setSearchQ("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
}}
|
||||
disabled={inCollection}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-default"
|
||||
>
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{billLabel(bill.bill_type, bill.bill_number)}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1">
|
||||
{bill.short_title || bill.title || "Untitled"}
|
||||
</span>
|
||||
{inCollection && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">Added</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bill list */}
|
||||
{collection.bills.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p className="text-sm">No bills yet — search to add some.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{collection.bills.map((bill) => (
|
||||
<div
|
||||
key={bill.bill_id}
|
||||
className="flex items-center gap-3 px-4 py-3 bg-card border border-border rounded-lg group"
|
||||
>
|
||||
<Link href={`/bills/${bill.bill_id}`} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{billLabel(bill.bill_type, bill.bill_number)}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{bill.short_title || bill.title || "Untitled"}
|
||||
</span>
|
||||
</div>
|
||||
{bill.latest_action_date && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Latest action: {formatDate(bill.latest_action_date)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => removeBillMutation.mutate(bill.bill_id)}
|
||||
className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||
title="Remove from collection"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
frontend/app/collections/page.tsx
Normal file
163
frontend/app/collections/page.tsx
Normal file
@@ -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 (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bookmark className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-xl font-bold">My Collections</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="bg-card border border-border rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Collection name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newPublic}
|
||||
onChange={(e) => setNewPublic(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Public collection</span>
|
||||
<span className="text-xs text-muted-foreground">(share link works either way)</span>
|
||||
</label>
|
||||
{formError && <p className="text-xs text-destructive">{formError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? "Creating…" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowForm(false); setFormError(""); setNewName(""); }}
|
||||
className="px-3 py-1.5 text-sm text-muted-foreground rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">Loading collections…</div>
|
||||
) : !collections || collections.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Bookmark className="w-8 h-8 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No collections yet — create one to start grouping bills.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{collections.map((c: Collection) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="bg-card border border-border rounded-lg px-4 py-3 flex items-center gap-3 group"
|
||||
>
|
||||
<Link href={`/collections/${c.id}`} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{c.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded shrink-0">
|
||||
{c.bill_count} {c.bill_count === 1 ? "bill" : "bills"}
|
||||
</span>
|
||||
{c.is_public ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
|
||||
<Globe className="w-3 h-3" /> Public
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<Lock className="w-3 h-3" /> Private
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Created {new Date(c.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${c.name}"? This cannot be undone.`)) {
|
||||
deleteMutation.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Delete collection"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/app/share/brief/[token]/page.tsx
Normal file
78
frontend/app/share/brief/[token]/page.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Minimal header */}
|
||||
<header className="border-b border-border bg-card px-6 py-3 flex items-center gap-2">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<Link href="/" className="font-semibold text-sm hover:opacity-70 transition-opacity">
|
||||
PocketVeto
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
{isLoading && (
|
||||
<div className="text-center py-20 text-muted-foreground text-sm">Loading…</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Brief not found or link is invalid.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Bill label + title */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
{billLabel(data.bill.bill_type, data.bill.bill_number)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold leading-snug">
|
||||
{data.bill.short_title || data.bill.title || "Untitled Bill"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Full brief */}
|
||||
<AIBriefCard brief={data.brief} />
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Link
|
||||
href={`/bills/${data.bill.bill_id}`}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-border hover:bg-accent transition-colors"
|
||||
>
|
||||
View full bill page <ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Track this bill on PocketVeto →
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/app/share/collection/[token]/page.tsx
Normal file
94
frontend/app/share/collection/[token]/page.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Minimal header */}
|
||||
<header className="border-b border-border bg-card px-6 py-3 flex items-center gap-2">
|
||||
<Landmark className="w-5 h-5 text-primary" />
|
||||
<Link href="/" className="font-semibold text-sm hover:opacity-70 transition-opacity">
|
||||
PocketVeto
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
{isLoading && (
|
||||
<div className="text-center py-20 text-muted-foreground text-sm">Loading…</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-muted-foreground">Collection not found or link is invalid.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collection && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{collection.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{collection.bill_count} {collection.bill_count === 1 ? "bill" : "bills"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bill list */}
|
||||
{collection.bills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No bills in this collection.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{collection.bills.map((bill: Bill) => (
|
||||
<Link
|
||||
key={bill.bill_id}
|
||||
href={`/bills/${bill.bill_id}`}
|
||||
className="block bg-card border border-border rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
||||
{billLabel(bill.bill_type, bill.bill_number)}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{bill.short_title || bill.title || "Untitled"}
|
||||
</span>
|
||||
</div>
|
||||
{bill.latest_action_date && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Latest action: {formatDate(bill.latest_action_date)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Follow these bills on PocketVeto →
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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 <AIBriefCard brief={null} />;
|
||||
@@ -57,6 +65,23 @@ export function BriefPanel({ briefs }: BriefPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share button row */}
|
||||
{latest.share_token && (
|
||||
<div className="flex justify-end px-1">
|
||||
<button
|
||||
onClick={() => copyShareLink(latest)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy shareable link to this brief"
|
||||
>
|
||||
{copied ? (
|
||||
<><Check className="w-3.5 h-3.5 text-green-500" /> Link copied!</>
|
||||
) : (
|
||||
<><Share2 className="w-3.5 h-3.5" /> Share brief</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Latest brief */}
|
||||
<AIBriefCard brief={latest} />
|
||||
|
||||
|
||||
143
frontend/components/bills/CollectionPicker.tsx
Normal file
143
frontend/components/bills/CollectionPicker.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Add to collection"
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
open
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-56 bg-card border border-border rounded-lg shadow-lg overflow-hidden">
|
||||
{!collections || collections.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||
No collections yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{collections.map((c: Collection) => (
|
||||
<CollectionPickerRow
|
||||
key={c.id}
|
||||
collection={c}
|
||||
billId={billId}
|
||||
onAdd={() => addMutation.mutate(c.id)}
|
||||
onRemove={() => removeMutation.mutate(c.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<Link
|
||||
href="/collections"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
New collection →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li>
|
||||
<button
|
||||
onClick={inCollection ? onRemove : onAdd}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<span className="w-4 h-4 shrink-0 flex items-center justify-center">
|
||||
{inCollection && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</span>
|
||||
<span className="truncate flex-1">{collection.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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<Collection[]>("/api/collections").then((r) => r.data),
|
||||
create: (name: string, is_public: boolean) =>
|
||||
apiClient.post<Collection>("/api/collections", { name, is_public }).then((r) => r.data),
|
||||
get: (id: number) =>
|
||||
apiClient.get<CollectionDetail>(`/api/collections/${id}`).then((r) => r.data),
|
||||
update: (id: number, data: { name?: string; is_public?: boolean }) =>
|
||||
apiClient.patch<Collection>(`/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<CollectionDetail>(`/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<CollectionDetail>(`/api/share/collection/${token}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Notes
|
||||
export const notesAPI = {
|
||||
get: (billId: string) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user