Phase 3 completion — Personal Workflow feature set is now complete.
Collections / Watchlists:
- New tables: collections (UUID share_token, slug, public/private) and
collection_bills (unique bill-per-collection constraint)
- Full CRUD API at /api/collections with bill add/remove endpoints
- Public share endpoint /api/collections/share/{token} (no auth)
- /collections list page with inline create form and delete
- /collections/[id] detail page: inline rename, public toggle,
copy-share-link, bill search/add/remove
- CollectionPicker bookmark-icon popover on bill detail pages
- Collections nav link in sidebar (auth-required)
Shareable Brief Links:
- share_token UUID column on bill_briefs (backfilled on migration)
- Unified public share router at /api/share (brief + collection)
- /share/brief/[token] — minimal layout, full AIBriefCard, CTAs
- /share/collection/[token] — minimal layout, bill list, CTA
- Share2 button in BriefPanel header row, "Link copied!" flash
AuthGuard: /collections → AUTH_REQUIRED; /share prefix → NO_SHELL_PATHS
Authored-By: Jack Levy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
338 lines
10 KiB
Python
338 lines
10 KiB
Python
from datetime import date, datetime
|
||
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):
|
||
ntfy_topic_url: str = ""
|
||
ntfy_auth_method: str = "none" # none | token | basic
|
||
ntfy_token: str = ""
|
||
ntfy_username: str = ""
|
||
ntfy_password: str = ""
|
||
ntfy_enabled: bool = False
|
||
rss_enabled: bool = False
|
||
rss_token: Optional[str] = None
|
||
# Digest
|
||
digest_enabled: bool = False
|
||
digest_frequency: str = "daily" # daily | weekly
|
||
# Quiet hours — stored as local-time hour integers (0-23); timezone is IANA name
|
||
quiet_hours_start: Optional[int] = None
|
||
quiet_hours_end: Optional[int] = None
|
||
timezone: Optional[str] = None # IANA name, e.g. "America/New_York"
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class NotificationSettingsUpdate(BaseModel):
|
||
ntfy_topic_url: Optional[str] = None
|
||
ntfy_auth_method: Optional[str] = None
|
||
ntfy_token: Optional[str] = None
|
||
ntfy_username: Optional[str] = None
|
||
ntfy_password: Optional[str] = None
|
||
ntfy_enabled: Optional[bool] = None
|
||
rss_enabled: Optional[bool] = None
|
||
digest_enabled: Optional[bool] = None
|
||
digest_frequency: Optional[str] = None
|
||
quiet_hours_start: Optional[int] = None
|
||
quiet_hours_end: Optional[int] = None
|
||
timezone: Optional[str] = None # IANA name sent by the browser on save
|
||
|
||
|
||
class NotificationEventSchema(BaseModel):
|
||
id: int
|
||
bill_id: str
|
||
event_type: str
|
||
payload: Optional[Any] = None
|
||
dispatched_at: Optional[datetime] = None
|
||
created_at: datetime
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class NtfyTestRequest(BaseModel):
|
||
ntfy_topic_url: str
|
||
ntfy_auth_method: str = "none"
|
||
ntfy_token: str = ""
|
||
ntfy_username: str = ""
|
||
ntfy_password: str = ""
|
||
|
||
|
||
class FollowModeTestRequest(BaseModel):
|
||
mode: str # pocket_veto | pocket_boost
|
||
event_type: str # new_document | new_amendment | bill_updated
|
||
|
||
|
||
class NotificationTestResult(BaseModel):
|
||
status: str # "ok" | "error"
|
||
detail: str
|
||
event_count: Optional[int] = None # RSS only
|
||
|
||
T = TypeVar("T")
|
||
|
||
|
||
class PaginatedResponse(BaseModel, Generic[T]):
|
||
items: list[T]
|
||
total: int
|
||
page: int
|
||
per_page: int
|
||
pages: int
|
||
|
||
|
||
# ── Member ────────────────────────────────────────────────────────────────────
|
||
|
||
class MemberSchema(BaseModel):
|
||
bioguide_id: str
|
||
name: str
|
||
first_name: Optional[str] = None
|
||
last_name: Optional[str] = None
|
||
party: Optional[str] = None
|
||
state: Optional[str] = None
|
||
chamber: Optional[str] = None
|
||
district: Optional[str] = None
|
||
photo_url: Optional[str] = None
|
||
official_url: Optional[str] = None
|
||
congress_url: Optional[str] = None
|
||
birth_year: Optional[str] = None
|
||
address: Optional[str] = None
|
||
phone: Optional[str] = None
|
||
terms_json: Optional[list[Any]] = None
|
||
leadership_json: Optional[list[Any]] = None
|
||
sponsored_count: Optional[int] = None
|
||
cosponsored_count: Optional[int] = None
|
||
latest_trend: Optional["MemberTrendScoreSchema"] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
# ── Bill Brief ────────────────────────────────────────────────────────────────
|
||
|
||
class BriefSchema(BaseModel):
|
||
id: int
|
||
brief_type: str = "full"
|
||
summary: Optional[str] = None
|
||
key_points: Optional[list[Any]] = None
|
||
risks: Optional[list[Any]] = None
|
||
deadlines: Optional[list[dict[str, Any]]] = None
|
||
topic_tags: Optional[list[str]] = None
|
||
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}
|
||
|
||
|
||
# ── Bill Action ───────────────────────────────────────────────────────────────
|
||
|
||
class BillActionSchema(BaseModel):
|
||
id: int
|
||
action_date: Optional[date] = None
|
||
action_text: Optional[str] = None
|
||
action_type: Optional[str] = None
|
||
chamber: Optional[str] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
# ── News Article ──────────────────────────────────────────────────────────────
|
||
|
||
class NewsArticleSchema(BaseModel):
|
||
id: int
|
||
source: Optional[str] = None
|
||
headline: Optional[str] = None
|
||
url: Optional[str] = None
|
||
published_at: Optional[datetime] = None
|
||
relevance_score: Optional[float] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
# ── Trend Score ───────────────────────────────────────────────────────────────
|
||
|
||
class TrendScoreSchema(BaseModel):
|
||
score_date: date
|
||
newsapi_count: int
|
||
gnews_count: int
|
||
gtrends_score: float
|
||
composite_score: float
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class MemberTrendScoreSchema(BaseModel):
|
||
score_date: date
|
||
newsapi_count: int
|
||
gnews_count: int
|
||
gtrends_score: float
|
||
composite_score: float
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class MemberNewsArticleSchema(BaseModel):
|
||
id: int
|
||
source: Optional[str] = None
|
||
headline: Optional[str] = None
|
||
url: Optional[str] = None
|
||
published_at: Optional[datetime] = None
|
||
relevance_score: Optional[float] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
# ── Bill ──────────────────────────────────────────────────────────────────────
|
||
|
||
class BillSchema(BaseModel):
|
||
bill_id: str
|
||
congress_number: int
|
||
bill_type: str
|
||
bill_number: int
|
||
title: Optional[str] = None
|
||
short_title: Optional[str] = None
|
||
introduced_date: Optional[date] = None
|
||
latest_action_date: Optional[date] = None
|
||
latest_action_text: Optional[str] = None
|
||
status: Optional[str] = None
|
||
chamber: Optional[str] = None
|
||
congress_url: Optional[str] = None
|
||
sponsor: Optional[MemberSchema] = None
|
||
latest_brief: Optional[BriefSchema] = None
|
||
latest_trend: Optional[TrendScoreSchema] = None
|
||
updated_at: Optional[datetime] = None
|
||
has_document: bool = False
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class BillDetailSchema(BillSchema):
|
||
actions: list[BillActionSchema] = []
|
||
news_articles: list[NewsArticleSchema] = []
|
||
trend_scores: list[TrendScoreSchema] = []
|
||
briefs: list[BriefSchema] = []
|
||
has_document: bool = False
|
||
|
||
|
||
# ── Follow ────────────────────────────────────────────────────────────────────
|
||
|
||
class FollowCreate(BaseModel):
|
||
follow_type: str # bill | member | topic
|
||
follow_value: str
|
||
|
||
|
||
class FollowSchema(BaseModel):
|
||
id: int
|
||
user_id: int
|
||
follow_type: str
|
||
follow_value: str
|
||
follow_mode: str = "neutral"
|
||
created_at: datetime
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class FollowModeUpdate(BaseModel):
|
||
follow_mode: str
|
||
|
||
|
||
# ── Settings ──────────────────────────────────────────────────────────────────
|
||
|
||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||
|
||
class UserCreate(BaseModel):
|
||
email: str
|
||
password: str
|
||
|
||
|
||
class UserResponse(BaseModel):
|
||
id: int
|
||
email: str
|
||
is_admin: bool
|
||
notification_prefs: dict
|
||
created_at: Optional[datetime] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class TokenResponse(BaseModel):
|
||
access_token: str
|
||
token_type: str = "bearer"
|
||
user: "UserResponse"
|
||
|
||
|
||
# ── Settings ──────────────────────────────────────────────────────────────────
|
||
|
||
class SettingUpdate(BaseModel):
|
||
key: str
|
||
value: str
|
||
|
||
|
||
class SettingsResponse(BaseModel):
|
||
llm_provider: str
|
||
llm_model: str
|
||
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
|