diff --git a/PocketVeto — Feature Roadmap.md b/PocketVeto — Feature Roadmap.md index 76623a6..a1ea744 100644 --- a/PocketVeto — Feature Roadmap.md +++ b/PocketVeto — Feature Roadmap.md @@ -52,7 +52,7 @@ ### Phase 3 — Personal Workflow - [ ] **Collections / Watchlists** — `collections` (id, user_id, name, slug, is_public) + `collection_bills` join table. UI to create/manage collections and filter dashboard by collection. Shareable via public slug URL (read-only for non-owners). -- [ ] **Personal Notes** — `bill_notes` table (user_id, bill_id, content, stance, tags, pinned). Shown on bill detail page. Private; optionally pin to top of the bill detail view. +- [x] **Personal Notes** — `bill_notes` table (user_id, bill_id, content, pinned). Collapsible panel on bill detail page. Pinned notes float above the brief. Private — auth-gated, never shown to guests. - [ ] **Shareable Links** — UUID token on briefs and collections → public read-only view, no login required. Same token system for both. No expiry by default. UUID (not sequential) to prevent enumeration. - [x] **Weekly Digest** — Celery beat task (Monday 8:30 AM UTC), queries followed bills for changes in the past 7 days, formats a low-noise summary, dispatches via ntfy + RSS. Admin can trigger immediately from Manual Controls. diff --git a/backend/alembic/versions/0014_add_bill_notes.py b/backend/alembic/versions/0014_add_bill_notes.py new file mode 100644 index 0000000..fac5043 --- /dev/null +++ b/backend/alembic/versions/0014_add_bill_notes.py @@ -0,0 +1,32 @@ +"""Add bill_notes table + +Revision ID: 0014 +Revises: 0013 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0014" +down_revision = "0013" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "bill_notes", + 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("bill_id", sa.String(), sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("pinned", sa.Boolean(), nullable=False, server_default="false"), + 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(), onupdate=sa.func.now()), + sa.UniqueConstraint("user_id", "bill_id", name="uq_bill_notes_user_bill"), + ) + op.create_index("ix_bill_notes_user_id", "bill_notes", ["user_id"]) + op.create_index("ix_bill_notes_bill_id", "bill_notes", ["bill_id"]) + + +def downgrade(): + op.drop_table("bill_notes") diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py new file mode 100644 index 0000000..bb664a0 --- /dev/null +++ b/backend/app/api/notes.py @@ -0,0 +1,89 @@ +""" +Bill Notes API — private per-user notes on individual bills. +One note per (user, bill). PUT upserts, DELETE removes. +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.dependencies import get_current_user +from app.database import get_db +from app.models.bill import Bill +from app.models.note import BillNote +from app.models.user import User +from app.schemas.schemas import BillNoteSchema, BillNoteUpsert + +router = APIRouter() + + +@router.get("/{bill_id}", response_model=BillNoteSchema) +async def get_note( + bill_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(BillNote).where( + BillNote.user_id == current_user.id, + BillNote.bill_id == bill_id, + ) + ) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="No note for this bill") + return note + + +@router.put("/{bill_id}", response_model=BillNoteSchema) +async def upsert_note( + bill_id: str, + body: BillNoteUpsert, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + bill = await db.get(Bill, bill_id) + if not bill: + raise HTTPException(status_code=404, detail="Bill not found") + + result = await db.execute( + select(BillNote).where( + BillNote.user_id == current_user.id, + BillNote.bill_id == bill_id, + ) + ) + note = result.scalar_one_or_none() + + if note: + note.content = body.content + note.pinned = body.pinned + else: + note = BillNote( + user_id=current_user.id, + bill_id=bill_id, + content=body.content, + pinned=body.pinned, + ) + db.add(note) + + await db.commit() + await db.refresh(note) + return note + + +@router.delete("/{bill_id}", status_code=204) +async def delete_note( + bill_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(BillNote).where( + BillNote.user_id == current_user.id, + BillNote.bill_id == bill_id, + ) + ) + note = result.scalar_one_or_none() + if not note: + raise HTTPException(status_code=404, detail="No note for this bill") + await db.delete(note) + await db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 28602ba..0d68670 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 +from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes from app.config import settings as config app = FastAPI( @@ -28,3 +28,4 @@ app.include_router(settings.router, prefix="/api/settings", tags=["settings"]) 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"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1860f62..5fb031b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.follow import Follow from app.models.member import Member from app.models.member_interest import MemberTrendScore, MemberNewsArticle from app.models.news import NewsArticle +from app.models.note import BillNote from app.models.notification import NotificationEvent from app.models.setting import AppSetting from app.models.trend import TrendScore @@ -15,6 +16,7 @@ __all__ = [ "BillAction", "BillDocument", "BillBrief", + "BillNote", "Follow", "Member", "MemberTrendScore", diff --git a/backend/app/models/bill.py b/backend/app/models/bill.py index 4722f12..3ba7475 100644 --- a/backend/app/models/bill.py +++ b/backend/app/models/bill.py @@ -39,6 +39,7 @@ class Bill(Base): news_articles = relationship("NewsArticle", back_populates="bill", order_by="desc(NewsArticle.published_at)") trend_scores = relationship("TrendScore", back_populates="bill", order_by="desc(TrendScore.score_date)") committee_bills = relationship("CommitteeBill", back_populates="bill") + notes = relationship("BillNote", back_populates="bill", cascade="all, delete-orphan") __table_args__ = ( Index("ix_bills_congress_number", "congress_number"), diff --git a/backend/app/models/note.py b/backend/app/models/note.py new file mode 100644 index 0000000..a295b16 --- /dev/null +++ b/backend/app/models/note.py @@ -0,0 +1,26 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class BillNote(Base): + __tablename__ = "bill_notes" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + bill_id = Column(String, ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False) + content = Column(Text, nullable=False) + pinned = Column(Boolean, nullable=False, default=False) + 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="bill_notes") + bill = relationship("Bill", back_populates="notes") + + __table_args__ = ( + UniqueConstraint("user_id", "bill_id", name="uq_bill_notes_user_bill"), + Index("ix_bill_notes_user_id", "user_id"), + Index("ix_bill_notes_bill_id", "bill_id"), + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index dd6ef28..9603977 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -19,3 +19,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") diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 613963b..7622ed7 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -4,6 +4,26 @@ from typing import Any, Generic, Optional, TypeVar from pydantic import BaseModel +# ── Notifications ────────────────────────────────────────────────────────────── + +# ── Bill Notes ──────────────────────────────────────────────────────────────── + +class BillNoteSchema(BaseModel): + id: int + bill_id: str + content: str + pinned: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class BillNoteUpsert(BaseModel): + content: str + pinned: bool = False + + # ── Notifications ────────────────────────────────────────────────────────────── class NotificationSettingsResponse(BaseModel): diff --git a/frontend/app/bills/[id]/page.tsx b/frontend/app/bills/[id]/page.tsx index e9ebe5d..7616b56 100644 --- a/frontend/app/bills/[id]/page.tsx +++ b/frontend/app/bills/[id]/page.tsx @@ -1,11 +1,13 @@ "use client"; import { use, useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { ArrowLeft, ExternalLink, FileX, User } from "lucide-react"; import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills"; import { BriefPanel } from "@/components/bills/BriefPanel"; import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel"; +import { NotesPanel } from "@/components/bills/NotesPanel"; import { ActionTimeline } from "@/components/bills/ActionTimeline"; import { TrendChart } from "@/components/bills/TrendChart"; import { NewsPanel } from "@/components/bills/NewsPanel"; @@ -20,6 +22,15 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin const { data: trendData } = useBillTrend(billId, 30); const { data: newsArticles, refetch: refetchNews } = useBillNews(billId); + // Fetch the user's note so we know if it's pinned before rendering + const { data: note } = useQuery({ + queryKey: ["note", billId], + queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)), + enabled: true, + retry: false, + throwOnError: false, + }); + // When the bill page is opened with no stored articles, the backend queues // a Celery news-fetch task that takes a few seconds to complete. // Retry up to 3 times (every 6 s) so articles appear without a manual refresh. @@ -104,41 +115,51 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin {/* Content grid */}
+ {/* Pinned note floats above briefs */} + {note?.pinned && } + {bill.briefs.length > 0 ? ( <> + {!note?.pinned && } ) : bill.has_document ? ( -
-

Analysis pending

-

- Bill text was retrieved but has not yet been analyzed. Check back shortly. -

-
- ) : ( -
-
- - No bill text published + <> +
+

Analysis pending

+

+ Bill text was retrieved but has not yet been analyzed. Check back shortly. +

-

- As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "} - no official text has been received for{" "} - {billLabel(bill.bill_type, bill.bill_number)}. - Analysis will be generated automatically once text is published on Congress.gov. -

- {bill.congress_url && ( - - Check status on Congress.gov - - )} -
+ {!note?.pinned && } + + ) : ( + <> +
+
+ + No bill text published +
+

+ As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "} + no official text has been received for{" "} + {billLabel(bill.bill_type, bill.bill_number)}. + Analysis will be generated automatically once text is published on Congress.gov. +

+ {bill.congress_url && ( + + Check status on Congress.gov + + )} +
+ {!note?.pinned && } + )} s.token); + const qc = useQueryClient(); + const queryKey = ["note", billId]; + + const { data: note, isLoading } = useQuery({ + queryKey, + queryFn: () => notesAPI.get(billId), + enabled: !!token, + retry: false, // 404 = no note; don't retry + throwOnError: false, + }); + + const [open, setOpen] = useState(false); + const [content, setContent] = useState(""); + const [pinned, setPinned] = useState(false); + const [saved, setSaved] = useState(false); + const textareaRef = useRef(null); + + // Sync form from loaded note + useEffect(() => { + if (note) { + setContent(note.content); + setPinned(note.pinned); + } + }, [note]); + + // Auto-resize textarea + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [content, open]); + + const upsert = useMutation({ + mutationFn: () => notesAPI.upsert(billId, content, pinned), + onSuccess: (updated) => { + qc.setQueryData(queryKey, updated); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }, + }); + + const remove = useMutation({ + mutationFn: () => notesAPI.delete(billId), + onSuccess: () => { + qc.removeQueries({ queryKey }); + setContent(""); + setPinned(false); + setOpen(false); + }, + }); + + // Don't render for guests + if (!token) return null; + if (isLoading) return null; + + const hasNote = !!note; + const isDirty = hasNote + ? content !== note.content || pinned !== note.pinned + : content.trim().length > 0; + + return ( +
+ {/* Header / toggle */} + + + {open && ( +
+