Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff75f9a00 | ||
|
|
761ffa85d9 | ||
|
|
21dd784fbb | ||
|
|
e12567ea3c | ||
|
|
7844367bd2 | ||
|
|
80343d3782 | ||
|
|
a2146a4f0b |
15
DEPLOYING.md
15
DEPLOYING.md
@@ -55,7 +55,6 @@ ENCRYPTION_SECRET_KEY= # generate: python -c "from cryptography.fernet import F
|
|||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_USER=congress
|
POSTGRES_USER=congress
|
||||||
POSTGRES_PASSWORD=your-strong-password
|
|
||||||
POSTGRES_DB=pocketveto
|
POSTGRES_DB=pocketveto
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
@@ -70,6 +69,18 @@ OPENAI_API_KEY=sk-...
|
|||||||
OPENAI_MODEL=gpt-4o-mini
|
OPENAI_MODEL=gpt-4o-mini
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database password (secrets file)
|
||||||
|
|
||||||
|
The database password lives in a plain file rather than `.env` so that any characters work with no escaping needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p secrets
|
||||||
|
openssl rand -base64 32 | tr -d '+/=' | cut -c1-32 > secrets/db_password
|
||||||
|
chmod 600 secrets/db_password
|
||||||
|
```
|
||||||
|
|
||||||
|
The `secrets/` directory is gitignored. Docker mounts `secrets/db_password` read-only into each container; the backend reads it automatically to build the database URL. Never commit this file.
|
||||||
|
|
||||||
Other providers (swap in place of the OpenAI block):
|
Other providers (swap in place of the OpenAI block):
|
||||||
```env
|
```env
|
||||||
# Anthropic
|
# Anthropic
|
||||||
@@ -112,6 +123,8 @@ SMTP_FROM=alerts@yourdomain.com
|
|||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** Make sure `secrets/db_password` exists before running this command (see step 2). Docker Compose will fail to start if the secrets file is missing.
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
1. Pull base images (postgres, redis, nginx, node)
|
1. Pull base images (postgres, redis, nginx, node)
|
||||||
2. Build the API, worker, beat, and frontend images
|
2. Build the API, worker, beat, and frontend images
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ import app.models # noqa: F401 — registers all models with Base.metadata
|
|||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# Override sqlalchemy.url from environment if set
|
# Override sqlalchemy.url — prefer env var, then config.py (which reads secrets file)
|
||||||
sync_url = os.environ.get("SYNC_DATABASE_URL")
|
sync_url = os.environ.get("SYNC_DATABASE_URL")
|
||||||
|
if not sync_url:
|
||||||
|
try:
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
sync_url = app_settings.SYNC_DATABASE_URL
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if sync_url:
|
if sync_url:
|
||||||
config.set_main_option("sqlalchemy.url", sync_url)
|
config.set_main_option("sqlalchemy.url", sync_url)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
- db_password
|
- db_password
|
||||||
volumes:
|
volumes:
|
||||||
- ./postgres/data:/var/lib/postgresql/data
|
- ./postgres/data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-congress} -d ${POSTGRES_DB:-pocketveto}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-congress} -d ${POSTGRES_DB:-pocketveto}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -19,6 +20,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./redis/data:/data
|
- ./redis/data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
- POSTGRES_HOST=postgres
|
- POSTGRES_HOST=postgres
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
restart: unless-stopped
|
||||||
secrets:
|
secrets:
|
||||||
- db_password
|
- db_password
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -61,6 +64,7 @@ services:
|
|||||||
- POSTGRES_HOST=postgres
|
- POSTGRES_HOST=postgres
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
restart: unless-stopped
|
||||||
secrets:
|
secrets:
|
||||||
- db_password
|
- db_password
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -81,6 +85,7 @@ services:
|
|||||||
- POSTGRES_HOST=postgres
|
- POSTGRES_HOST=postgres
|
||||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
restart: unless-stopped
|
||||||
secrets:
|
secrets:
|
||||||
- db_password
|
- db_password
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -93,6 +98,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -126,21 +126,21 @@ function ModeFilterSection({
|
|||||||
<input type="checkbox" checked={!!filters["new_document"]}
|
<input type="checkbox" checked={!!filters["new_document"]}
|
||||||
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
|
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
|
||||||
className="mt-0.5 rounded" />
|
className="mt-0.5 rounded" />
|
||||||
<div><span className="text-sm">New bill text</span><span className="text-xs text-muted-foreground ml-2">The full text of the bill is published</span></div>
|
<div className="min-w-0"><span className="text-sm">New bill text</span><span className="text-xs text-muted-foreground ml-2">The full text of the bill is published</span></div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
<label className="flex items-start gap-3 py-1.5 cursor-pointer">
|
||||||
<input type="checkbox" checked={!!filters["new_amendment"]}
|
<input type="checkbox" checked={!!filters["new_amendment"]}
|
||||||
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
|
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
|
||||||
className="mt-0.5 rounded" />
|
className="mt-0.5 rounded" />
|
||||||
<div><span className="text-sm">Amendment filed</span><span className="text-xs text-muted-foreground ml-2">An amendment is filed against the bill</span></div>
|
<div className="min-w-0"><span className="text-sm">Amendment filed</span><span className="text-xs text-muted-foreground ml-2">An amendment is filed against the bill</span></div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="py-1.5">
|
<div className="py-1.5">
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
|
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
|
||||||
onChange={toggleMilestones} className="mt-0.5 rounded" />
|
onChange={toggleMilestones} className="mt-0.5 rounded" />
|
||||||
<div><span className="text-sm font-medium">Milestones</span><span className="text-xs text-muted-foreground ml-2">Select all milestone types</span></div>
|
<div className="min-w-0"><span className="text-sm font-medium">Milestones</span><span className="text-xs text-muted-foreground ml-2">Select all milestone types</span></div>
|
||||||
</label>
|
</label>
|
||||||
<div className="ml-6 mt-1 space-y-0.5">
|
<div className="ml-6 mt-1 space-y-0.5">
|
||||||
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
|
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
|
||||||
@@ -150,7 +150,7 @@ function ModeFilterSection({
|
|||||||
<input type="checkbox" checked={!!filters[k]}
|
<input type="checkbox" checked={!!filters[k]}
|
||||||
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
|
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
|
||||||
className="mt-0.5 rounded" />
|
className="mt-0.5 rounded" />
|
||||||
<div><span className="text-sm">{row.label}</span><span className="text-xs text-muted-foreground ml-2">{row.hint}</span></div>
|
<div className="min-w-0"><span className="text-sm">{row.label}</span><span className="text-xs text-muted-foreground ml-2">{row.hint}</span></div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -484,7 +484,7 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel tab bar */}
|
{/* Channel tab bar */}
|
||||||
<div className="flex gap-0 border-b border-border -mx-6 px-6">
|
<div className="flex gap-0 border-b border-border -mx-6 px-6 overflow-x-auto">
|
||||||
{([
|
{([
|
||||||
{ key: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled },
|
{ key: "ntfy", label: "ntfy", icon: Bell, active: settings?.ntfy_enabled },
|
||||||
{ key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
|
{ key: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
|
||||||
@@ -494,7 +494,7 @@ export default function NotificationsPage() {
|
|||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setActiveChannelTab(key)}
|
onClick={() => setActiveChannelTab(key)}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`shrink-0 flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeChannelTab === key
|
activeChannelTab === key
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
@@ -688,12 +688,12 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div className="flex gap-0 border-b border-border">
|
<div className="flex gap-0 border-b border-border overflow-x-auto">
|
||||||
{MODES.map((mode) => (
|
{MODES.map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode.key}
|
key={mode.key}
|
||||||
onClick={() => setActiveFilterTab(mode.key as ModeKey)}
|
onClick={() => setActiveFilterTab(mode.key as ModeKey)}
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`shrink-0 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeFilterTab === mode.key
|
activeFilterTab === mode.key
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
@@ -704,7 +704,7 @@ export default function NotificationsPage() {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveFilterTab("discovery")}
|
onClick={() => setActiveFilterTab("discovery")}
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`shrink-0 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeFilterTab === "discovery"
|
activeFilterTab === "discovery"
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
@@ -749,7 +749,7 @@ export default function NotificationsPage() {
|
|||||||
<input type="checkbox" checked={!!enabled}
|
<input type="checkbox" checked={!!enabled}
|
||||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||||
className="rounded" />
|
className="rounded" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium">Notify me about bills from member follows</span>
|
<span className="text-sm font-medium">Notify me about bills from member follows</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -777,7 +777,7 @@ export default function NotificationsPage() {
|
|||||||
{unmutedMembers.length > 0 && (
|
{unmutedMembers.length > 0 && (
|
||||||
<select value=""
|
<select value=""
|
||||||
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }}
|
onChange={(e) => { const id = e.target.value; if (id) setMutedMemberIds((prev) => [...prev, id]); }}
|
||||||
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
|
className="text-base px-2 py-1.5 bg-background border border-border rounded-md">
|
||||||
<option value="" disabled>Mute a member…</option>
|
<option value="" disabled>Mute a member…</option>
|
||||||
{unmutedMembers.map((f) => (
|
{unmutedMembers.map((f) => (
|
||||||
<option key={f.follow_value} value={f.follow_value}>
|
<option key={f.follow_value} value={f.follow_value}>
|
||||||
@@ -807,7 +807,7 @@ export default function NotificationsPage() {
|
|||||||
<input type="checkbox" checked={!!enabled}
|
<input type="checkbox" checked={!!enabled}
|
||||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||||
className="rounded" />
|
className="rounded" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium">Notify me about bills from topic follows</span>
|
<span className="text-sm font-medium">Notify me about bills from topic follows</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
<span className="text-xs text-muted-foreground ml-2">{src.description}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -835,7 +835,7 @@ export default function NotificationsPage() {
|
|||||||
{unmutedTopics.length > 0 && (
|
{unmutedTopics.length > 0 && (
|
||||||
<select value=""
|
<select value=""
|
||||||
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }}
|
onChange={(e) => { const tag = e.target.value; if (tag) setMutedTopicTags((prev) => [...prev, tag]); }}
|
||||||
className="text-xs px-2 py-1.5 bg-background border border-border rounded-md">
|
className="text-base px-2 py-1.5 bg-background border border-border rounded-md">
|
||||||
<option value="" disabled>Mute a topic…</option>
|
<option value="" disabled>Mute a topic…</option>
|
||||||
{unmutedTopics.map((f) => (
|
{unmutedTopics.map((f) => (
|
||||||
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option>
|
<option key={f.follow_value} value={f.follow_value}>{f.follow_value}</option>
|
||||||
@@ -884,14 +884,14 @@ export default function NotificationsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-muted-foreground">From</label>
|
<label className="text-sm text-muted-foreground">From</label>
|
||||||
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
|
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
|
||||||
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
|
className="px-2 py-1.5 text-base bg-background border border-border rounded-md">
|
||||||
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-muted-foreground">To</label>
|
<label className="text-sm text-muted-foreground">To</label>
|
||||||
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
|
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
|
||||||
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
|
className="px-2 py-1.5 text-base bg-background border border-border rounded-md">
|
||||||
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1048,9 +1048,9 @@ export default function NotificationsPage() {
|
|||||||
const billTitle = p.bill_title as string | undefined;
|
const billTitle = p.bill_title as string | undefined;
|
||||||
const briefSummary = p.brief_summary as string | undefined;
|
const briefSummary = p.brief_summary as string | undefined;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 py-3">
|
<div className="flex items-start gap-3 py-3 overflow-hidden">
|
||||||
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
|
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-medium">{meta.label}</span>
|
<span className="text-xs font-medium">{meta.label}</span>
|
||||||
{billLabel && (
|
{billLabel && (
|
||||||
@@ -1062,7 +1062,7 @@ export default function NotificationsPage() {
|
|||||||
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
|
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
{billTitle && (
|
{billTitle && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1 break-all">{billTitle}</p>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const src = p.source as string | undefined;
|
const src = p.source as string | undefined;
|
||||||
@@ -1085,7 +1085,7 @@ export default function NotificationsPage() {
|
|||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
{briefSummary && (
|
{briefSummary && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 break-words">{briefSummary}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showDispatch && (
|
{showDispatch && (
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Authenticated or guest browsing: render the full app shell
|
// Authenticated or guest browsing: render the full app shell
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="fixed inset-0 flex overflow-hidden bg-background">
|
||||||
{/* Desktop sidebar — hidden on mobile */}
|
{/* Desktop sidebar — hidden on mobile */}
|
||||||
<div className="hidden md:flex">
|
<div className="hidden md:flex">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -59,7 +59,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content column */}
|
{/* Content column */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<MobileHeader onMenuClick={() => setDrawerOpen(true)} />
|
<MobileHeader onMenuClick={() => setDrawerOpen(true)} />
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">
|
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">
|
||||||
|
|||||||
Reference in New Issue
Block a user