feat: personal notes on bill detail pages

- bill_notes table (migration 0014): user_id, bill_id, content, pinned,
  created_at, updated_at; unique constraint (user_id, bill_id)
- BillNote SQLAlchemy model with back-refs on User and Bill
- GET/PUT/DELETE /api/notes/{bill_id} — auth-required, one note per (user, bill)
- NotesPanel component: collapsible, auto-resize textarea, pin toggle,
  save + delete; shows last-saved date and pin indicator in collapsed header
- Pinned notes render above BriefPanel; unpinned render below DraftLetterPanel
- Guests see nothing (token guard in component + query disabled)

Co-Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 22:14:52 -05:00
parent 128c8e9257
commit 62a217cb22
13 changed files with 393 additions and 30 deletions

View File

@@ -52,7 +52,7 @@
### Phase 3 — Personal Workflow ### 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). - [ ] **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. - [ ] **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. - [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.

View File

@@ -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")

89
backend/app/api/notes.py Normal file
View File

@@ -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()

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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 from app.config import settings as config
app = FastAPI( 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(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(health.router, prefix="/api/health", tags=["health"]) app.include_router(health.router, prefix="/api/health", tags=["health"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"]) app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
app.include_router(notes.router, prefix="/api/notes", tags=["notes"])

View File

@@ -4,6 +4,7 @@ from app.models.follow import Follow
from app.models.member import Member from app.models.member import Member
from app.models.member_interest import MemberTrendScore, MemberNewsArticle from app.models.member_interest import MemberTrendScore, MemberNewsArticle
from app.models.news import NewsArticle from app.models.news import NewsArticle
from app.models.note import BillNote
from app.models.notification import NotificationEvent from app.models.notification import NotificationEvent
from app.models.setting import AppSetting from app.models.setting import AppSetting
from app.models.trend import TrendScore from app.models.trend import TrendScore
@@ -15,6 +16,7 @@ __all__ = [
"BillAction", "BillAction",
"BillDocument", "BillDocument",
"BillBrief", "BillBrief",
"BillNote",
"Follow", "Follow",
"Member", "Member",
"MemberTrendScore", "MemberTrendScore",

View File

@@ -39,6 +39,7 @@ class Bill(Base):
news_articles = relationship("NewsArticle", back_populates="bill", order_by="desc(NewsArticle.published_at)") 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)") trend_scores = relationship("TrendScore", back_populates="bill", order_by="desc(TrendScore.score_date)")
committee_bills = relationship("CommitteeBill", back_populates="bill") committee_bills = relationship("CommitteeBill", back_populates="bill")
notes = relationship("BillNote", back_populates="bill", cascade="all, delete-orphan")
__table_args__ = ( __table_args__ = (
Index("ix_bills_congress_number", "congress_number"), Index("ix_bills_congress_number", "congress_number"),

View File

@@ -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"),
)

View File

@@ -19,3 +19,4 @@ class User(Base):
follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan") follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan")
notification_events = relationship("NotificationEvent", 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")

View File

@@ -4,6 +4,26 @@ from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel 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 ────────────────────────────────────────────────────────────── # ── Notifications ──────────────────────────────────────────────────────────────
class NotificationSettingsResponse(BaseModel): class NotificationSettingsResponse(BaseModel):

View File

@@ -1,11 +1,13 @@
"use client"; "use client";
import { use, useEffect, useRef } from "react"; import { use, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, ExternalLink, FileX, User } from "lucide-react"; import { ArrowLeft, ExternalLink, FileX, User } from "lucide-react";
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills"; import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
import { BriefPanel } from "@/components/bills/BriefPanel"; import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel"; import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { NotesPanel } from "@/components/bills/NotesPanel";
import { ActionTimeline } from "@/components/bills/ActionTimeline"; import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { TrendChart } from "@/components/bills/TrendChart"; import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel"; 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: trendData } = useBillTrend(billId, 30);
const { data: newsArticles, refetch: refetchNews } = useBillNews(billId); 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 // 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. // 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. // Retry up to 3 times (every 6 s) so articles appear without a manual refresh.
@@ -104,19 +115,27 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
{/* Content grid */} {/* Content grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
<div className="md:col-span-2 space-y-6"> <div className="md:col-span-2 space-y-6">
{/* Pinned note floats above briefs */}
{note?.pinned && <NotesPanel billId={bill.bill_id} />}
{bill.briefs.length > 0 ? ( {bill.briefs.length > 0 ? (
<> <>
<BriefPanel briefs={bill.briefs} /> <BriefPanel briefs={bill.briefs} />
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} /> <DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
</> </>
) : bill.has_document ? ( ) : bill.has_document ? (
<>
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2"> <div className="bg-card border border-border rounded-lg p-6 text-center space-y-2">
<p className="text-sm font-medium text-muted-foreground">Analysis pending</p> <p className="text-sm font-medium text-muted-foreground">Analysis pending</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Bill text was retrieved but has not yet been analyzed. Check back shortly. Bill text was retrieved but has not yet been analyzed. Check back shortly.
</p> </p>
</div> </div>
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
</>
) : ( ) : (
<>
<div className="bg-card border border-border rounded-lg p-6 space-y-3"> <div className="bg-card border border-border rounded-lg p-6 space-y-3">
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<FileX className="w-4 h-4 shrink-0" /> <FileX className="w-4 h-4 shrink-0" />
@@ -139,6 +158,8 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
</a> </a>
)} )}
</div> </div>
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
</>
)} )}
<ActionTimeline <ActionTimeline
actions={bill.actions} actions={bill.actions}

View File

@@ -0,0 +1,151 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { StickyNote, Pin, PinOff, Trash2, ChevronDown, ChevronUp, Save } from "lucide-react";
import { notesAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
interface NotesPanelProps {
billId: string;
}
export function NotesPanel({ billId }: NotesPanelProps) {
const token = useAuthStore((s) => 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<HTMLTextAreaElement>(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 (
<div className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header / toggle */}
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-sm hover:bg-accent transition-colors"
>
<span className="flex items-center gap-2 font-medium">
<StickyNote className="w-4 h-4 text-muted-foreground" />
My Note
{hasNote && (
<span className="flex items-center gap-1 text-xs text-muted-foreground font-normal">
{note.pinned && <Pin className="w-3 h-3" />}
{new Date(note.updated_at).toLocaleDateString()}
</span>
)}
</span>
{open ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{open && (
<div className="px-4 pb-4 space-y-3 border-t border-border pt-3">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a private note about this bill…"
rows={3}
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none overflow-hidden"
/>
<div className="flex items-center justify-between gap-3">
{/* Left: pin toggle + delete */}
<div className="flex items-center gap-3">
<button
onClick={() => setPinned((v) => !v)}
title={pinned ? "Unpin note" : "Pin to top of page"}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
pinned
? "border-primary text-primary bg-primary/10"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{pinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{pinned ? "Pinned" : "Pin"}
</button>
{hasNote && (
<button
onClick={() => remove.mutate()}
disabled={remove.isPending}
title="Delete note"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Right: save */}
<button
onClick={() => upsert.mutate()}
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Save className="w-3 h-3" />
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
</button>
</div>
<p className="text-xs text-muted-foreground">Private only visible to you.</p>
</div>
)}
</div>
);
}

View File

@@ -86,6 +86,16 @@ export const billsAPI = {
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data), apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
}; };
// Notes
export const notesAPI = {
get: (billId: string) =>
apiClient.get<import("./types").BillNote>(`/api/notes/${billId}`).then((r) => r.data),
upsert: (billId: string, content: string, pinned: boolean) =>
apiClient.put<import("./types").BillNote>(`/api/notes/${billId}`, { content, pinned }).then((r) => r.data),
delete: (billId: string) =>
apiClient.delete(`/api/notes/${billId}`),
};
// Members // Members
export const membersAPI = { export const membersAPI = {
list: (params?: Record<string, unknown>) => list: (params?: Record<string, unknown>) =>

View File

@@ -158,6 +158,15 @@ export interface SettingsData {
pytrends_enabled: boolean; pytrends_enabled: boolean;
} }
export interface BillNote {
id: number;
bill_id: string;
content: string;
pinned: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationSettings { export interface NotificationSettings {
ntfy_topic_url: string; ntfy_topic_url: string;
ntfy_auth_method: string; // "none" | "token" | "basic" ntfy_auth_method: string; // "none" | "token" | "basic"