diff --git a/README.md b/README.md index 87533f19..10d4075c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ A self-hosted investment tracking platform for managing portfolios, analyzing pe ## Preview -[folio-demo.webm](https://github.com/user-attachments/assets/bb43d330-5571-4a6c-aeb7-e9ac58a1e26a) +[folio-demo.webm](https://github.com/user-attachments/assets/59817665-1af4-4d10-b0fa-cb92d40d8e04) ## Stack @@ -39,6 +39,7 @@ A self-hosted investment tracking platform for managing portfolios, analyzing pe |----------|-----------------------------------| | Frontend | SvelteKit, TypeScript, Tailwind, shadcn-svelte, bits-ui | | Backend | FastAPI, SQLAlchemy (async) | +| Cache | Valkey (Redis-compatible) | | Database | PostgreSQL | | Data | yfinance, tiingo, ngnmarket, tradingview | | Infra | Docker, Docker Compose | @@ -63,7 +64,7 @@ If you just want to run Folio locally for personal use, pull the pre-built image make prod-setup ``` -Open `api/.env.prod` and `web/.env.local` and fill in your values (database credentials, API URL, `SECRET_KEY`, etc.), then: +Open `api/.env.prod` and `web/.env.prod` and fill in your values. The provided examples already include the correct Docker service hostnames for the database (`db`) and cache (`cache`) — you only need to set `SECRET_KEY` and any API keys you plan to use. A local PostgreSQL database and Valkey cache are bundled in the stack. You can override database credentials via the Compose environment variables (`DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT`). Then: ```bash make prod-pull @@ -82,8 +83,8 @@ Once running: | Service | URL | |----------|----------------------------| | Frontend | http://localhost:3000 | -| API | http://localhost:8000 | -| API Docs | http://localhost:8000/docs | +| API | http://localhost:8010 | +| API Docs | http://localhost:8010/docs | ### Running for Development diff --git a/api/.env.example b/api/.env.example index 0f8a22df..518a7940 100644 --- a/api/.env.example +++ b/api/.env.example @@ -18,9 +18,13 @@ REFRESH_TOKEN_EXPIRE_DAYS=7 YFINANCE_CACHE_TTL=3600 YFINANCE_PRICE_HISTORY_CACHE_TTL=86400 MARKET_DATA_CACHE_TTL=108000 + +# Market data TIINGO_API_KEY=value NGNMARKET_API_BASE_URL=https://api.ngnmarket.com/v1/ NGNMARKET_API_KEY=value +RAPID_API_BASE_URL=https://tradingview-data1.p.rapidapi.com/ +RAPID_API_KEY=value # Scheduler SCHEDULER_ENABLED=true diff --git a/api/.env.prod.example b/api/.env.prod.example index 31e4a004..f7408f61 100644 --- a/api/.env.prod.example +++ b/api/.env.prod.example @@ -1,22 +1,24 @@ # Platform -DATABASE_URL= -REDIS_URL= +DATABASE_URL=postgresql+asyncpg://folio:folio_dev_password@db:5432/folio_prod +REDIS_URL=redis://cache:6379/0 ENABLE_REGISTRATION=false # API SECRET_KEY=secret-key ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 API_ENV=production -API_PORT=8000 +API_PORT=8010 # Auth ACCESS_TOKEN_EXPIRE_MINUTES=15 REFRESH_TOKEN_EXPIRE_DAYS=7 -# Market data +# Market cache data YFINANCE_CACHE_TTL=3600 YFINANCE_PRICE_HISTORY_CACHE_TTL=86400 -MARKET_DATA_CACHE_TTL=108000 +MARKET_DATA_CACHE_TTL=10800 + +# Market data TIINGO_API_KEY=value NGNMARKET_API_BASE_URL=https://api.ngnmarket.com/v1/ NGNMARKET_API_KEY=value diff --git a/api/infrastructure/config/env.py b/api/infrastructure/config/env.py index c50e7e8e..f3be0c27 100644 --- a/api/infrastructure/config/env.py +++ b/api/infrastructure/config/env.py @@ -41,7 +41,7 @@ class EnvironSettings(BaseModel): # Leave empty for same-origin or reverse-proxy setups. COOKIE_DOMAIN: str = os.getenv("COOKIE_DOMAIN", "") - # Market data + # Market cache data YFINANCE_CACHE_TTL: int = int(os.getenv("YFINANCE_CACHE_TTL", "3600")) # 1 hour YFINANCE_PRICE_HISTORY_CACHE_TTL: int = int( os.getenv("YFINANCE_PRICE_HISTORY_CACHE_TTL", "86400") @@ -49,6 +49,8 @@ class EnvironSettings(BaseModel): MARKET_DATA_CACHE_TTL: int = int( os.getenv("MARKET_DATA_CACHE_TTL", "10800") ) # 3 hours — cached in Valkey + + # Market data TIINGO_API_KEY: str = os.getenv("TIINGO_API_KEY", "") NGNMARKET_API_BASE_URL: str = os.getenv( "NGNMARKET_API_BASE_URL", "https://api.ngnmarket.com/v1" diff --git a/api/infrastructure/db/seed.py b/api/infrastructure/db/seed.py index f3f8cd2b..c6a6b074 100644 --- a/api/infrastructure/db/seed.py +++ b/api/infrastructure/db/seed.py @@ -19,7 +19,7 @@ from infrastructure.db.session import async_session _SEED_USER_ID = UUID("00000000-0000-0000-0000-000000000001") -_SEED_USER_EMAIL = "demo@folio.local" +_SEED_USER_EMAIL = "demo@example.com" log = logging.getLogger(__name__) @@ -193,7 +193,7 @@ def _price_on(prices: dict[date, int], d: date) -> int: def _build_trades( portfolio_id, - asset_map: dict[str, uuid4], + asset_map: dict[str, UUID], price_map: dict[str, dict[date, int]], ) -> list[dict]: @@ -340,23 +340,32 @@ async def seed() -> None: log.info("Seeding demo data…") - # Ensure seed user exists - existing_user = await session.execute( - select(UserModel).where(UserModel.id == _SEED_USER_ID) - ) - if not existing_user.scalar_one_or_none(): - import bcrypt - - hashed = bcrypt.hashpw(b"demo1234", bcrypt.gensalt()).decode("utf-8") - session.add( - UserModel( - id=_SEED_USER_ID, + # Ensure seed user exists (may have been pre-created by a migration + # with a placeholder password and is_active=False). Upsert handles + # both create and overwrite in a single statement. + import bcrypt + from sqlalchemy.dialects.postgresql import insert as pg_insert + + hashed = bcrypt.hashpw(b"demo1234", bcrypt.gensalt()).decode("utf-8") + stmt = ( + pg_insert(UserModel) + .values( + id=_SEED_USER_ID, + email=_SEED_USER_EMAIL, + hashed_password=hashed, + is_active=True, + ) + .on_conflict_do_update( + index_elements=["id"], + set_=dict( email=_SEED_USER_EMAIL, hashed_password=hashed, is_active=True, - ) + ), ) - await session.flush() + ) + await session.execute(stmt) + await session.flush() # Portfolio portfolio_id = uuid4() @@ -374,7 +383,7 @@ async def seed() -> None: rng = random.Random(_RNG_SEED) weekdays = _weekdays(_START, _END) price_map: dict[str, dict[date, int]] = {} - asset_map: dict[str, uuid4] = {} + asset_map: dict[str, UUID] = {} for a in _ASSETS: asset_id = uuid4() diff --git a/api/pyproject.toml b/api/pyproject.toml index 6ef2c1aa..e67d320b 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "folio-api" -version = "1.22.2" +version = "1.22.3" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" diff --git a/api/uv.lock b/api/uv.lock index 00384dec..a35896ce 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -618,7 +618,7 @@ wheels = [ [[package]] name = "folio-api" -version = "1.22.2" +version = "1.22.3" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e4b639f1..dc1c58b8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,22 +1,62 @@ services: + db: + image: postgres:16-alpine + container_name: folio-prod-db + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER:-folio} + POSTGRES_PASSWORD: ${DB_PASSWORD:-folio_dev_password} + POSTGRES_DB: ${DB_NAME:-folio_prod} + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "${DB_PORT:-5433}:5432" + volumes: + - postgres_prod_data:/var/lib/postgresql/data + - ./infra/scripts/ensure-db-entrypoint.sh:/ensure-db-entrypoint.sh:ro + entrypoint: ["/ensure-db-entrypoint.sh"] + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${DB_USER:-folio} -d ${DB_NAME:-folio_prod}", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - folio-prod-network + + cache: + image: valkey/valkey:9.0.4 + container_name: folio-prod-cache + restart: unless-stopped + command: redis-server --save 60 1 --loglevel warning + volumes: + - folio_prod_cache_data:/var/lib/redis/data + networks: + - folio-prod-network + expose: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + api: image: ghcr.io/aybruhm/folio-api:latest container_name: folio-ghcr-api restart: unless-stopped env_file: ./api/.env.prod - environment: - DATABASE_URL: ${DATABASE_URL} ports: - - "${API_PORT:-8000}:8000" + - "${API_PORT:-8010}:8000" depends_on: db: condition: service_healthy - command: > - python -m alembic upgrade head && - python -m uvicorn main:app - --host 0.0.0.0 - --port ${API_PORT:-8000} - --workers 2 + cache: + condition: service_healthy + command: python -m uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 networks: - folio-prod-network healthcheck: @@ -41,7 +81,7 @@ services: image: ghcr.io/aybruhm/folio-web:latest container_name: folio-ghcr-web restart: unless-stopped - env_file: ./web/.env.local + env_file: ./web/.env.prod ports: - "${WEB_PORT:-3000}:3000" depends_on: @@ -60,3 +100,9 @@ networks: folio-prod-network: name: folio-prod-network driver: bridge + +volumes: + postgres_prod_data: + driver: local + folio_prod_cache_data: + driver: local diff --git a/infra/scripts/ensure-db-entrypoint.sh b/infra/scripts/ensure-db-entrypoint.sh new file mode 100755 index 00000000..a35e38c6 --- /dev/null +++ b/infra/scripts/ensure-db-entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# --------------------------------------------------------------------------- +# Custom PostgreSQL entrypoint wrapper +# +# Starts PostgreSQL via the original entrypoint, waits for readiness, then +# ensures $POSTGRES_DB exists (creating it if missing). Finally, brings +# PostgreSQL back to the foreground so the container stays alive. +# +# All POSTGRES_* environment variables are injected by docker-compose. +# --------------------------------------------------------------------------- +set -e + +# Start the original PostgreSQL entrypoint in the background. +# We capture its PID so we can bring it to the foreground later. +/usr/local/bin/docker-entrypoint.sh postgres & +POSTGRES_PID=$! + +# Wait until PostgreSQL is accepting connections. +echo "Waiting for PostgreSQL to become ready..." +until pg_isready -U "${POSTGRES_USER:-folio}" -d postgres > /dev/null 2>&1; do + sleep 2 +done +echo "PostgreSQL is ready." + +# Resolve the target database name (same default as docker-compose.prod.yml). +DB_NAME="${POSTGRES_DB:-folio_prod}" +DB_USER="${POSTGRES_USER:-folio}" + +# Check whether the database already exists. +EXISTS=$(psql -U "$DB_USER" -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME';" 2>/dev/null || true) + +if [ "$EXISTS" = "1" ]; then + echo "Database '$DB_NAME' already exists. Nothing to do." +else + echo "Database '$DB_NAME' does not exist. Creating..." + createdb -U "$DB_USER" "$DB_NAME" + echo "Database '$DB_NAME' created successfully." +fi + +# Bring PostgreSQL back to the foreground. +# If postgres exits, the container exits (as expected). +wait $POSTGRES_PID diff --git a/web/.env.prod.example b/web/.env.prod.example new file mode 100644 index 00000000..c3d4d2a2 --- /dev/null +++ b/web/.env.prod.example @@ -0,0 +1,21 @@ +# API Configuration +# Secret key should be the same with api/.env.prod (if in production) +PUBLIC_API_BASE_URL=/api/v1/ +PRIVATE_API_URL=http://api:8000 +SECRET_KEY=dev-secret-key-change-in-production + +# Node Environment +NODE_ENV=production + +# Enable/disable the sign-up page and registration API. +# Set to "false" to hide the register page and reject +# registration API calls (e.g. in production). +PUBLIC_ENABLE_REGISTRATION=true + +# Client-side IndexedDB cache TTLs (in seconds). +# These should mirror the corresponding backend MARKET_DATA_CACHE_TTL, +# YFINANCE_PRICE_HISTORY_CACHE_TTL, and YFINANCE_CACHE_TTL values in api/.env.prod. +PUBLIC_MARKET_DATA_CACHE_TTL=10800 +PUBLIC_PRICE_HISTORY_CACHE_TTL=86400 +PUBLIC_YFINANCE_CACHE_TTL=3600 +PUBLIC_FX_RATES_CACHE_TTL=10800 diff --git a/web/.gitignore b/web/.gitignore index 963607ef..f10fabb7 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -4,3 +4,4 @@ build/ .env .env.* !.env.local.example +!.env.prod.example diff --git a/web/package-lock.json b/web/package-lock.json index 4f5fb11c..c53f55c0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio-web", - "version": "1.22.2", + "version": "1.22.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio-web", - "version": "1.22.2", + "version": "1.22.3", "dependencies": { "@sveltejs/adapter-node": "^5.5.4", "axios": "^1.15.2", diff --git a/web/package.json b/web/package.json index 21126a04..f204c9a5 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "folio-web", - "version": "1.22.2", + "version": "1.22.3", "type": "module", "scripts": { "dev": "svelte-kit sync && vite",