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
|
||||
POSTGRES_USER=congress
|
||||
POSTGRES_PASSWORD=your-strong-password
|
||||
POSTGRES_DB=pocketveto
|
||||
|
||||
# Redis
|
||||
@@ -70,6 +69,18 @@ OPENAI_API_KEY=sk-...
|
||||
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):
|
||||
```env
|
||||
# Anthropic
|
||||
@@ -112,6 +123,8 @@ SMTP_FROM=alerts@yourdomain.com
|
||||
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:
|
||||
1. Pull base images (postgres, redis, nginx, node)
|
||||
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
|
||||
|
||||
# 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")
|
||||
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:
|
||||
config.set_main_option("sqlalchemy.url", sync_url)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- db_password
|
||||
volumes:
|
||||
- ./postgres/data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-congress} -d ${POSTGRES_DB:-pocketveto}"]
|
||||
interval: 5s
|
||||
@@ -19,6 +20,7 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./redis/data:/data
|
||||
healthcheck:
|
||||
@@ -41,6 +43,7 @@ services:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
- db_password
|
||||
depends_on:
|
||||
@@ -61,6 +64,7 @@ services:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
- db_password
|
||||
depends_on:
|
||||
@@ -81,6 +85,7 @@ services:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
restart: unless-stopped
|
||||
secrets:
|
||||
- db_password
|
||||
depends_on:
|
||||
@@ -93,6 +98,7 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
|
||||
@@ -126,21 +126,21 @@ function ModeFilterSection({
|
||||
<input type="checkbox" checked={!!filters["new_document"]}
|
||||
onChange={(e) => onChange({ ...filters, new_document: e.target.checked })}
|
||||
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 className="flex items-start gap-3 py-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={!!filters["new_amendment"]}
|
||||
onChange={(e) => onChange({ ...filters, new_amendment: e.target.checked })}
|
||||
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>
|
||||
|
||||
<div className="py-1.5">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input ref={milestoneCheckRef} type="checkbox" checked={milestoneState === "on"}
|
||||
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>
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
{(["vote", "presidential", "committee_report", "calendar", "procedural"] as const).map((k) => {
|
||||
@@ -150,7 +150,7 @@ function ModeFilterSection({
|
||||
<input type="checkbox" checked={!!filters[k]}
|
||||
onChange={(e) => onChange({ ...filters, [k]: e.target.checked })}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -484,7 +484,7 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
|
||||
{/* 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: "email", label: "Email", icon: Mail, active: settings?.email_enabled },
|
||||
@@ -494,7 +494,7 @@ export default function NotificationsPage() {
|
||||
<button
|
||||
key={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
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -688,12 +688,12 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={mode.key}
|
||||
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
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -704,7 +704,7 @@ export default function NotificationsPage() {
|
||||
))}
|
||||
<button
|
||||
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"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
@@ -749,7 +749,7 @@ export default function NotificationsPage() {
|
||||
<input type="checkbox" checked={!!enabled}
|
||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||
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-xs text-muted-foreground ml-2">{src.description}</span>
|
||||
</div>
|
||||
@@ -777,7 +777,7 @@ export default function NotificationsPage() {
|
||||
{unmutedMembers.length > 0 && (
|
||||
<select value=""
|
||||
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>
|
||||
{unmutedMembers.map((f) => (
|
||||
<option key={f.follow_value} value={f.follow_value}>
|
||||
@@ -807,7 +807,7 @@ export default function NotificationsPage() {
|
||||
<input type="checkbox" checked={!!enabled}
|
||||
onChange={(e) => setDiscoveryFilters((prev) => ({ ...prev, [src.key]: { ...prev[src.key], enabled: e.target.checked } }))}
|
||||
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-xs text-muted-foreground ml-2">{src.description}</span>
|
||||
</div>
|
||||
@@ -835,7 +835,7 @@ export default function NotificationsPage() {
|
||||
{unmutedTopics.length > 0 && (
|
||||
<select value=""
|
||||
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>
|
||||
{unmutedTopics.map((f) => (
|
||||
<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">
|
||||
<label className="text-sm text-muted-foreground">From</label>
|
||||
<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>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">To</label>
|
||||
<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>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -1048,9 +1048,9 @@ export default function NotificationsPage() {
|
||||
const billTitle = p.bill_title as string | undefined;
|
||||
const briefSummary = p.brief_summary as string | undefined;
|
||||
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}`} />
|
||||
<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">
|
||||
<span className="text-xs font-medium">{meta.label}</span>
|
||||
{billLabel && (
|
||||
@@ -1062,7 +1062,7 @@ export default function NotificationsPage() {
|
||||
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
{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;
|
||||
@@ -1085,7 +1085,7 @@ export default function NotificationsPage() {
|
||||
) : null;
|
||||
})()}
|
||||
{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>
|
||||
{showDispatch && (
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Authenticated or guest browsing: render the full app shell
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="fixed inset-0 flex overflow-hidden bg-background">
|
||||
{/* Desktop sidebar — hidden on mobile */}
|
||||
<div className="hidden md:flex">
|
||||
<Sidebar />
|
||||
@@ -59,7 +59,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* 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)} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">
|
||||
|
||||
Reference in New Issue
Block a user