feat: rebuild fiscal clone architecture and harden coolify deployment

This commit is contained in:
2026-02-23 21:10:39 -05:00
parent cae7cbb98f
commit 04e5caf4e1
61 changed files with 3826 additions and 2923 deletions

View File

@@ -1,20 +1,28 @@
# Database
# PostgreSQL
DATABASE_URL=postgres://postgres:postgres@localhost:5432/fiscal
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=fiscal
POSTGRES_HOST=localhost
# Backend
# API service
PORT=3001
NODE_ENV=development
JWT_SECRET=change-this-to-a-random-secret-key
BETTER_AUTH_SECRET=change-this-to-a-random-secret-key
BETTER_AUTH_BASE_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000
BETTER_AUTH_SECRET=replace-with-strong-random-secret
BETTER_AUTH_BASE_URL=http://localhost:3001
SEC_USER_AGENT=Fiscal Clone <support@example.com>
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
# In Coolify this must be the public backend URL (e.g. https://api.fiscal.example.com)
# OpenClaw Integration
OPENCLAW_WEBHOOK_URL=https://discord.com/api/webhooks/...
# OpenClaw / ZeroClaw (OpenAI-compatible)
OPENCLAW_BASE_URL=http://localhost:4000
OPENCLAW_API_KEY=replace-with-your-agent-key
OPENCLAW_MODEL=zeroclaw
# Queue tuning
TASK_HEARTBEAT_SECONDS=15
TASK_STALE_SECONDS=120
TASK_MAX_ATTEMPTS=3

View File

@@ -1,174 +1,9 @@
# Better Auth Migration
# Better Auth Migration (Archived)
## Overview
Migrated from NextAuth v5 (beta) to Better Auth for unified authentication across both Elysia (backend) and Next.js (frontend).
This document described the pre-2.0 incremental migration path.
## Backend Changes
### Installation
- Added `better-auth@1.4.18` package
- Added `pg@8.18.0` for PostgreSQL connection pool
### New Files
- `backend/src/auth.ts` - Better Auth instance configuration
- `backend/src/routes/better-auth.ts` - Route handler for auth endpoints
- `backend/src/better-auth-migrate.ts` - Database migration script
### Modified Files
- `backend/src/index.ts` - Replaced custom auth routes with Better Auth routes
- Removed custom JWT-based authentication routes (`backend/src/routes/auth.ts` can be deleted after migration)
### Database Schema
New tables added:
- `session` - Session management
- `account` - OAuth/credential account storage
- `verification` - Email verification tokens
Existing `users` table extended with:
- `email_verified` (BOOLEAN)
- `image` (TEXT)
### Migration Steps
1. Run the migration script:
```bash
cd backend
bun run src/better-auth-migrate.ts
```
2. Set environment variables:
```env
BETTER_AUTH_SECRET=<generate with: openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3001
```
## Frontend Changes
### Installation
- Added `better-auth` package (includes React client)
### New Files
- `frontend/lib/better-auth.ts` - Better Auth client instance
### Modified Files
- `frontend/lib/auth.ts` - Updated to use Better Auth session
- `frontend/app/auth/signin/page.tsx` - Updated to use Better Auth methods
- `frontend/app/auth/signup/page.tsx` - Updated to use Better Auth methods
- Removed `frontend/app/api/auth/[...nextauth]/route.ts` - No longer needed
### Authentication Methods
Better Auth supports:
- Email/password (`signIn.email`, `signUp.email`)
- OAuth providers (`signIn.social`):
- GitHub (`signIn.social({ provider: 'github' })`)
- Google (`signIn.social({ provider: 'google' })`)
### Session Management
```typescript
import { useSession } from '@/lib/better-auth';
// In client components
const { data: session } = useSession();
```
```typescript
import { authClient } from '@/lib/better-auth';
// In server components
const { data: session } = await authClient.getSession();
```
## Environment Variables
### Required
```env
# Backend
DATABASE_URL=postgres://user:password@localhost:5432/fiscal
BETTER_AUTH_SECRET=<32+ character random string>
BETTER_AUTH_URL=http://localhost:3001
# OAuth Providers
GITHUB_ID=<your github client id>
GITHUB_SECRET=<your github client secret>
GOOGLE_ID=<your google client id>
GOOGLE_SECRET=<your google client secret>
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
```
## API Endpoints
Better Auth provides these endpoints automatically (mounted at `/api/auth/*`):
### Email/Password
- `POST /api/auth/sign-up/email` - Sign up with email
- `POST /api/auth/sign-in/email` - Sign in with email
- `GET /api/auth/get-session` - Get current session
- `POST /api/auth/sign-out` - Sign out
### OAuth
- `GET /api/auth/sign-in/social` - Initiate OAuth flow
- `GET /api/auth/callback/*` - OAuth callback handler
### Session
- `GET /api/auth/get-session` - Get current session
- `POST /api/auth/update-session` - Update session data
## Key Differences from NextAuth
### NextAuth
- Configuration in route handler (`app/api/auth/[...nextauth]/route.ts`)
- Server-side session management with JWT
- Custom callback for session/user data
- Requires `signIn()` and `signOut()` from `next-auth/react`
### Better Auth
- Configuration in separate file (`backend/src/auth.ts`)
- Server and client components unified API
- Built-in session management with database storage
- `signIn.email`, `signIn.social`, `signOut` from `better-auth/react`
- Direct database access for user/session data
## Testing Checklist
- [ ] Run database migration: `bun run src/better-auth-migrate.ts`
- [ ] Start backend server
- [ ] Test email/password signup
- [ ] Test email/password login
- [ ] Test GitHub OAuth
- [ ] Test Google OAuth
- [ ] Test sign out
- [ ] Test protected routes redirect to sign in
- [ ] Test session persistence across page refreshes
## Rollback Plan
If issues arise, revert to NextAuth:
1. Restore `frontend/app/api/auth/[...nextauth]/route.ts`
2. Restore `frontend/app/auth/signin/page.tsx` and `frontend/app/auth/signup/page.tsx`
3. Restore `frontend/lib/auth.ts`
4. Remove `backend/src/auth.ts` and `backend/src/routes/better-auth.ts`
5. Restore custom auth routes in backend
## Benefits of Better Auth
1. **Unified Auth** - Single auth system for both backend and frontend
2. **Type Safety** - Better TypeScript support
3. **Database-Backed Sessions** - More secure than JWT
4. **Extensible** - Plugin system for 2FA, email verification, etc.
5. **Active Development** - More frequent updates and improvements
6. **Framework Agnostic** - Works with any backend framework
## Future Improvements
1. Enable email verification (Better Auth plugin)
2. Add two-factor authentication (Better Auth plugin)
3. Implement account management (password reset, email change)
4. Add session management UI (view active sessions, revoke)
5. Implement role-based access control (Better Auth plugin)
## Resources
- Better Auth Docs: https://www.better-auth.com/
- Better Auth GitHub: https://github.com/better-auth/better-auth
- Migration Guide: https://www.better-auth.com/docs/migration
The codebase has been rebuilt for Fiscal Clone 2.0. Use these sources instead:
- `README.md` for runtime and setup
- `backend/src/auth.ts` for Better Auth configuration
- `backend/src/db/migrate.ts` for current schema
- `docs/REBUILD_DECISIONS.md` for architecture rationale

View File

@@ -1,113 +1,78 @@
# Coolify Deployment Guide
# Coolify Deployment (Fiscal Clone 2.0)
This project is ready for deployment on Coolify.
This repository is deployable on Coolify using the root `docker-compose.yml`.
## Prerequisites
## What gets deployed
1. Coolify instance running
2. GitHub repository with this code
3. PostgreSQL database
- `frontend` (Next.js)
- `backend` (Elysia API + Better Auth)
- `worker` (durable async job processor)
- `postgres` (database)
## Deployment Steps
`backend` and `worker` auto-run migrations on startup:
- `bun run src/db/migrate.ts`
- then start API/worker process
### Option 1: Single Docker Compose App
## Coolify setup
1. Create a new Docker Compose application in Coolify
2. Connect your GitHub repository
3. Select the `docker-compose.yml` file in the root
4. Configure environment variables:
1. Create a **Docker Compose** app in Coolify.
2. Connect this repository.
3. Use compose file: `/docker-compose.yml`.
4. Add public domains:
- `frontend` service on port `3000` (example: `https://fiscal.example.com`)
- `backend` service on port `3001` (example: `https://api.fiscal.example.com`)
```
DATABASE_URL=postgres://postgres:your_password@postgres:5432/fiscal
## Required environment variables
Set these in Coolify before deploy:
```env
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_password
POSTGRES_PASSWORD=<strong-password>
POSTGRES_DB=fiscal
PORT=3001
BETTER_AUTH_SECRET=your-random-long-secret
BETTER_AUTH_BASE_URL=https://api.your-fiscal-domain.com
JWT_SECRET=your-jwt-secret-key-min-32-characters
GITHUB_ID=your-github-oauth-client-id
GITHUB_SECRET=your-github-oauth-client-secret
GOOGLE_ID=your-google-oauth-client-id
GOOGLE_SECRET=your-google-oauth-client-secret
NEXT_PUBLIC_API_URL=https://api.your-fiscal-domain.com
DATABASE_URL=postgres://postgres:<strong-password>@postgres:5432/fiscal
# Public URLs
FRONTEND_URL=https://fiscal.example.com
BETTER_AUTH_BASE_URL=https://api.fiscal.example.com
NEXT_PUBLIC_API_URL=https://api.fiscal.example.com
# Security
BETTER_AUTH_SECRET=<openssl rand -base64 32>
SEC_USER_AGENT=Fiscal Clone <ops@your-domain.com>
# Optional OpenClaw/ZeroClaw integration
OPENCLAW_BASE_URL=https://your-openclaw-endpoint
OPENCLAW_API_KEY=<token>
OPENCLAW_MODEL=zeroclaw
# Optional queue tuning
TASK_HEARTBEAT_SECONDS=15
TASK_STALE_SECONDS=120
TASK_MAX_ATTEMPTS=3
```
5. Deploy
## Important build note
### Option 2: Separate Applications
`NEXT_PUBLIC_API_URL` is compiled into the frontend bundle at build time. If you change it, trigger a new deploy/rebuild.
#### Backend
The frontend includes a safety fallback: if `NEXT_PUBLIC_API_URL` is accidentally set to an internal host like `http://backend:3001`, browser calls will fall back to `https://api.<frontend-host>`.
This is a fallback only; keep `NEXT_PUBLIC_API_URL` correct in Coolify.
1. Create a new application in Coolify
2. Source: GitHub
3. Branch: `main`
4. Build Context: `/backend`
5. Build Pack: `Dockerfile`
6. Environment Variables:
```
DATABASE_URL=postgres://...
PORT=3001
```
7. Deploy
#### Frontend
1. Create a new application in Coolify
2. Source: GitHub
3. Branch: `main`
4. Build Context: `/frontend`
5. Build Pack: `Dockerfile`
6. Environment Variables:
```
NEXT_PUBLIC_API_URL=https://your-backend-domain.com
```
7. Deploy
## Environment Variables
### Backend
- `DATABASE_URL` - PostgreSQL connection string
- `PORT` - Server port (default: 3001)
- `NODE_ENV` - Environment (development/production)
- `BETTER_AUTH_SECRET` - Required in production; use a long random secret
- `BETTER_AUTH_BASE_URL` - Public backend URL used for auth callbacks
### Frontend
- `NEXT_PUBLIC_API_URL` - Backend API URL
`NEXT_PUBLIC_API_URL` is used at image build time in the frontend Docker build.
Set it in Coolify before deploying so the generated client bundle points to the correct backend URL.
## Database Setup
The application will automatically create the database schema on startup. To manually run migrations:
## Post-deploy checks
1. API health:
```bash
docker exec -it <backend_container> bun run db:migrate
curl -f https://api.fiscal.example.com/api/health
```
2. Frontend loads and auth screens render.
3. Create user, add watchlist symbol, queue filing sync.
4. Confirm background tasks move `queued -> running -> completed` in dashboard.
## Monitoring
## Common pitfalls
Once deployed, add the application to OpenClaw's monitoring:
1. Add to `/data/workspace/memory/coolify-integration.md`
2. Set up Discord alerts for critical issues
3. Configure cron jobs for health checks
## Troubleshooting
### Database Connection Failed
- Check DATABASE_URL is correct
- Ensure PostgreSQL container is running
- Verify network connectivity
### Frontend Can't Connect to Backend
- Verify NEXT_PUBLIC_API_URL points to backend
- Check CORS settings in backend
- Ensure both containers are on same network
### Cron Jobs Not Running
- Check Elysia cron configuration
- Verify timezone settings
- Check logs for errors
- `NEXT_PUBLIC_API_URL` left as internal hostname (`http://backend:3001`) causes auth/API failures until fallback or proper config is applied.
- `FRONTEND_URL` missing/incorrect causes CORS/session issues.
- `BETTER_AUTH_BASE_URL` must be the public backend URL, not the internal container hostname.
- Deploying frontend and backend on unrelated domains can cause cookie/session headaches. Prefer same root domain (e.g. `fiscal.example.com` + `api.fiscal.example.com`).

View File

@@ -1,555 +1,5 @@
# Fiscal Clone - Direct Coolify Deployment
# Direct Coolify Deployment
Bypassing Gitea for direct Coolify deployment!
Use `/Users/francescobrassesco/Coding/fiscal clone/fiscal-clone/COOLIFY.md` as the canonical deployment guide for Fiscal Clone 2.0.
## Deployment Strategy
**Method:** File Upload (Simplest & Most Reliable)
### Why Direct to Coolify?
1. ✅ No Git repository issues
2. ✅ No SSL certificate problems
3. ✅ No authentication failures
4. ✅ Fast deployment
5. ✅ Easy rollback
6. ✅ Built-in environment variable management
## Deployment Steps
### Step 1: Prepare Files
**Already Ready:**
- Fiscal Clone code: `/data/workspace/fiscal-clone/`
- Docker Compose configured
- All features implemented
### Step 2: Deploy to Coolify
**In Coolify Dashboard:**
1. **Create New Application**
- Type: Docker Compose
- Name: `fiscal-clone-full-stack`
- Source: **File Upload**
2. **Upload Files**
- Create zip/tarball of `fiscal-clone/` folder
- Or select folder if Coolify supports directory upload
- Upload all files
3. **Configure Build Context**
- Build Context: `/`
- Docker Compose File: `docker-compose.yml`
4. **Configure Domains**
- **Frontend:** `fiscal.b11studio.xyz`
- **Backend API:** `api.fiscal.b11studio.xyz`
5. **Configure Environment Variables**
**Required Variables:**
```bash
DATABASE_URL=postgres://postgres:your-secure-password@postgres:5432/fiscal
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-secure-password
POSTGRES_DB=fiscal
JWT_SECRET=your-jwt-secret-key-min-32-characters
NEXT_PUBLIC_API_URL=http://backend:3001
```
**Optional OAuth Variables:**
```bash
# GitHub OAuth
GITHUB_ID=your-github-oauth-client-id
GITHUB_SECRET=your-github-oauth-client-secret
# Google OAuth
GOOGLE_ID=your-google-oauth-client-id
GOOGLE_SECRET=your-google-oauth-client-secret
```
6. **Deploy!**
- Click "Deploy" button
- Monitor deployment logs in Coolify
### Step 3: First Access
**After Deployment:**
1. **Access Frontend:** https://fiscal.b11studio.xyz
2. **Create Account:**
- Click "Sign Up"
- Enter email, name, password
- Click "Create Account"
3. **Login:** Use your new account to log in
4. **Add to Watchlist:**
- Go to "Watchlist"
- Add a stock ticker (e.g., AAPL)
- Wait for SEC filings to be fetched
5. **Add to Portfolio:**
- Go to "Portfolio"
- Add a holding
- Enter ticker, shares, average cost
### Step 4: Database Migrations
**Automatic or Manual:**
The database schema should automatically create on first run, but you can manually run migrations if needed:
**In Coolify Terminal:**
```bash
# Access backend container
docker exec -it <backend-container-name> sh
# Run migrations
bun run db:migrate
```
## Architecture
```
Coolify Server
└── Fiscal Clone Application
├── Frontend (Next.js 14)
│ ├── Domain: https://fiscal.b11studio.xyz
│ ├── Routes: /, /auth/*, /api/*, /portfolio, /filings, /watchlist
│ └── Environment: NEXT_PUBLIC_API_URL
├── Backend (Elysia.js + Bun)
│ ├── Port: 3001
│ ├── Routes: /api/*, /api/auth/*
│ └── Environment: DATABASE_URL, JWT_SECRET, etc.
└── Database (PostgreSQL 16)
├── User auth tables
├── Filings tables
├── Portfolio tables
└── Watchlist tables
```
## Health Checks
After deployment, verify all services are running:
### Backend Health
```bash
curl http://api.fiscal.b11studio.xyz/api/health
```
**Expected Response:**
```json
{
"status": "ok",
"timestamp": "2026-02-16T02:31:00.000Z",
"version": "1.0.0",
"database": "connected"
}
```
### Frontend Health
```bash
curl https://fiscal.b11studio.xyz
```
**Expected:** HTML page loads successfully
### Database Health
```bash
# Check Coolify dashboard
# PostgreSQL container should show as "healthy"
# All containers should be "running"
```
## Troubleshooting
### Application Won't Start
**Check Coolify Logs:**
- Navigate to application → Logs
- Look for database connection errors
- Check if PostgreSQL container is healthy
- Verify environment variables are correct
**Common Issues:**
1. **Database Connection Failed**
- Error: `connection refused` or `password authentication failed`
- Fix: Verify `DATABASE_URL` and `POSTGRES_PASSWORD` match
- Fix: Ensure PostgreSQL container is running and healthy
2. **Frontend Can't Connect to Backend**
- Error: `502 Bad Gateway` or `Connection refused`
- Fix: Verify `NEXT_PUBLIC_API_URL` is correct
- Fix: Check if backend is running
- Fix: Verify network connectivity between containers
3. **Authentication Fails**
- Error: `Invalid token` or `Authentication failed`
- Fix: Generate new `JWT_SECRET`
- Fix: Update backend container to restart
- Fix: Verify OAuth credentials (GitHub/Google) are correct
4. **Port Conflicts**
- Error: `Address already in use`
- Fix: Coolify automatically assigns ports
- Fix: Check Coolify logs for port conflicts
### Manual Restart
If application is in bad state:
1. In Coolify Dashboard → Application
2. Click "Restart" button
3. Wait for all containers to restart
4. Check logs for errors
### Rollback to Previous Version
If deployment breaks functionality:
1. In Coolify Dashboard → Application
2. Click "Deployments"
3. Select previous successful deployment
4. Click "Rollback"
5. Verify functionality
## Advanced Configuration
### Performance Tuning
**For High Load:**
In Coolify environment variables for backend:
```bash
# Database connection pooling
POSTGRES_DB_MAX_CONNECTIONS=200
POSTGRES_DB_MAX_IDLE_CONNECTIONS=50
POSTGRES_DB_CONNECTION_LIFETIME=1h
# Worker processes
PORT=3001
NODE_ENV=production
```
**Caching Configuration:**
Already configured with Redis. Add environment variable:
```bash
# Enable Redis caching
REDIS_ENABLED=true
REDIS_URL=redis://redis:6379
```
### SSL/TLS Configuration
Coolify automatically handles SSL with Let's Encrypt. Ensure:
- Domain DNS points correctly to Coolify
- Port 80 and 443 are open on VPS firewall
- Coolify Traefik proxy is running
## Monitoring
### Coolify Built-in Monitoring
1. **Application Logs:** View in Coolify Dashboard
2. **Container Logs:** Docker logs for each service
3. **Resource Usage:** CPU, Memory, Disk in Dashboard
4. **Health Checks:** Built-in health endpoints
### External Monitoring
Set up uptime monitoring:
**Services:**
- UptimeRobot
- Pingdom
- StatusCake
**URLs to Monitor:**
- Frontend: `https://fiscal.b11studio.xyz`
- Backend API: `http://api.fiscal.b11studio.xyz/api/health`
- Health Check: `http://api.fiscal.b11studio.xyz/health`
### Discord Alerts
Integrate with your Discord server:
**Create these webhooks in Coolify:**
1. **For Deployment Channel:**
- Coolify → Application → Webhooks
- Webhook URL: Your Discord webhook URL
- Events: Deployment status
2. **For Alert Channel:**
- Webhook URL: Your Discord webhook URL
- Events: Application failures, crashes
**Or use Coolify's built-in Discord integration** (if available)
## Backup Strategy
### Automatic Backups (Coolify)
Coolify provides:
- ✅ PostgreSQL automated backups
- ✅ Volume persistence across deployments
### Manual Backups
**Export Database:**
```bash
# In Coolify terminal
docker exec -it postgres pg_dump -U postgres -d fiscal > backup-$(date +%Y%m%d).sql
```
**Import Database:**
```bash
# In Coolify terminal
docker exec -i postgres psql -U postgres -d fiscal < backup-20260215.sql
```
**Download Data:**
```bash
# Access backend container
docker exec -it backend tar -czf /tmp/fiscal-backup.tar.gz /app/data
# Download from container (if Coolify provides file access)
# Or access via Coolify file browser (if available)
```
## Scaling
### Horizontal Scaling
**In Coolify:**
1. Application Settings → Resources
2. Increase replicas for frontend
3. Add more backend instances
4. Load balance with Traefik
### Database Scaling
**For High Load:**
1. Use separate PostgreSQL instance
2. Configure connection pooling
3. Use read replicas for queries
4. Optimize indexes
**Coolify provides:**
- Easy horizontal scaling
- Load balancing with Traefik
- Resource limits per container
## Security
### Environment Variables Security
**Never commit secrets!**
Use Coolify environment variables for sensitive data:
❌ DO NOT COMMIT:
- API keys
- Database passwords
- JWT secrets
- OAuth client secrets
- SMTP credentials
✅ DO USE:
- Coolify environment variables
- Encrypted storage in Coolify
- Separate .env files for local development
### Password Security
**Generate Strong Passwords:**
```bash
# Database password
openssl rand -base64 32 | tr -d '[:alnum:]'
# JWT secret
openssl rand -base64 32 | tr -d '[:alnum:]'
# Admin password (if needed)
openssl rand -base64 32 | tr -d '[:alnum:]'
```
**Store securely in Coolify:**
- DATABASE_URL=postgres://postgres:<secure-password>@postgres:5432/fiscal
- JWT_SECRET=<secure-random-32-characters>
- POSTGRES_PASSWORD=<secure-password>
### Access Control
**Discord Server:**
- Create private channels for admin discussions
- Limit sensitive information to private channels
- Use role-based access control
**Coolify:**
- Use Team permissions
- Restrict application access
- Enable 2FA (if available for Coolify account)
- Regular security updates
### Firewall Rules
Ensure VPS firewall allows:
```
# Inbound
80/tcp - HTTP (Traefik)
443/tcp - HTTPS (Traefik)
22/tcp - SSH (optional)
3000/tcp - Next.js
3001/tcp - Backend API
# Outbound
All (for Git operations, SEC API, Yahoo Finance)
```
## Update Strategy
### Update Process
**Current Version:** 1.0.0
**To Update:**
1. **Update Code Locally**
```bash
cd /data/workspace/fiscal-clone
git pull origin main
# Make changes
git add .
git commit -m "chore: Update Fiscal Clone"
git push origin main
```
2. **Deploy New Version in Coolify**
- Go to Coolify Dashboard → Application
- Click "Deploy" button
- Coolify pulls latest code and rebuilds
3. **Monitor Deployment**
- Watch logs in Coolify
- Verify all services are healthy
- Test all features
**Blue-Green Deployment:**
1. Deploy new version to new Coolify application
2. Switch DNS to new version
3. Verify new version works
4. Delete old version
### Rollback Strategy
If new deployment has issues:
1. In Coolify Dashboard → Deployments
2. Select previous stable version
3. Click "Rollback"
4. DNS switches back automatically
## CI/CD
### Coolify Built-in CI/CD
**Automatic Deployment:**
Configure webhooks in Coolify:
**GitHub:**
1. Repository Settings → Webhooks
2. Payload URL: Coolify provides this
3. Events: Push, Pull Request
4. Push to main branch triggers deployment
**GitLab:**
1. Repository Settings → Integrations
2. Deployments → Create deploy token
3. Configure in Coolify
**Gitea:**
1. Repository Settings → Webhooks
2. Gitea webhook URL: Coolify provides this
3. Events: Push events
### Pipeline
```
Push Code → GitHub/GitLab/Gitea
Coolify Webhook Triggered
New Build & Deploy
Application Deployed to Coolify
Health Checks & Monitoring
Live on https://fiscal.b11studio.xyz
```
## Documentation
### Links
- **Fiscal Clone README:** `/data/workspace/fiscal-clone/README.md`
- **Coolify Docs:** https://docs.coolify.io/
- **NextAuth Docs:** https://next-auth.js.org/
- **Elysia Docs:** https://elysiajs.com/
- **PostgreSQL Docs:** https://www.postgresql.org/docs/
### API Documentation
After deployment, access:
- Swagger UI: `https://api.fiscal.b11studio.xyz/swagger`
- API Docs: `/api/filings`, `/api/portfolio`, etc.
### Quick Reference
**Environment Variables:**
```bash
DATABASE_URL=postgres://postgres:password@postgres:5432/fiscal
JWT_SECRET=<your-jwt-secret>
NEXT_PUBLIC_API_URL=http://backend:3001
```
**Endpoints:**
- Frontend: https://fiscal.b11studio.xyz
- Backend API: http://api.fiscal.b11studio.xyz
- Health: http://api.fiscal.b11studio.xyz/api/health
- Swagger: https://api.fiscal.b11studio.xyz/swagger
```
## Success Criteria
Deployment is successful when:
- [ ] All containers are running
- [ ] PostgreSQL is healthy
- [ ] Frontend loads at https://fiscal.b11studio.xyz
- [ ] Backend API responds on /api/health
- [ ] Can create account and login
- [ ] Can add stocks to watchlist
- [ ] Can add holdings to portfolio
- [ ] SEC filings are being fetched
- [ ] Database tables created
- [ ] No errors in logs
---
**Deployment Version:** 2.0 (Direct to Coolify)
**Status:** Ready for deployment
**Last Updated:** 2026-02-16
This file is retained only as a compatibility entrypoint.

412
README.md
View File

@@ -1,367 +1,135 @@
# Fiscal Clone
# Fiscal Clone 2.0
Financial filings extraction and portfolio analytics powered by SEC EDGAR.
Ground-up rebuild of a `fiscal.ai`-style platform with:
- Better Auth for session-backed auth
- Next.js frontend
- high-throughput API service
- durable long-running task worker
- OpenClaw/ZeroClaw AI integration
- futuristic terminal UI language
## Features
## Feature Coverage
- **SEC Filings Extraction**
- 10-K, 10-Q, 8-K filings support
- Key metrics extraction (revenue, net income, assets, cash, debt)
- Real-time search and updates
- Authentication (email/password via Better Auth)
- Watchlist management
- SEC filings ingestion (10-K, 10-Q, 8-K)
- Filing analysis jobs (async AI pipeline)
- Portfolio holdings and summary analytics
- Price refresh jobs (async)
- AI portfolio insight jobs (async)
- Task tracking endpoint and UI polling
- **Portfolio Analytics**
- Stock holdings tracking
- Real-time price updates (Yahoo Finance API)
- Automatic P&L calculations
- Performance charts (pie chart allocation, line chart performance)
## Architecture
- **Watchlist Management**
- Add/remove stocks to watchlist
- Track company and sector information
- Quick access to filings and portfolio
- `frontend/`: Next.js App Router UI
- `backend/`: Elysia API + Better Auth + domain routes
- `backend/src/worker.ts`: durable queue worker
- `docs/REBUILD_DECISIONS.md`: one-by-one architecture decisions
- **Authentication**
- NextAuth.js with multiple providers
- GitHub, Google OAuth, Email/Password
- JWT-based session management with 30-day expiration
Runtime topology:
1. Frontend web app
2. Backend API
3. Worker process for long tasks
4. PostgreSQL
- **OpenClaw Integration**
- AI portfolio insights
- AI filing analysis
- Discord notification endpoints
## Tech Stack
- **Backend**: Elysia.js (Bun runtime)
- **Frontend**: Next.js 14 + TailwindCSS + Recharts
- **Database**: PostgreSQL with automatic P&L calculations
- **Data Sources**: SEC EDGAR API, Yahoo Finance API
- **Authentication**: NextAuth.js (GitHub, Google, Credentials)
- **Deployment**: Coolify (Docker Compose)
## Getting Started
### Prerequisites
- Node.js 20+
- Bun 1.0+
- PostgreSQL 16
- GitHub account
- Coolify instance with API access
### Installation
## Local Setup
```bash
# Clone repository
git clone https://git.b11studio.xyz/francy51/fiscal-clone.git
cd fiscal-clone
cp .env.example .env
```
# Install backend dependencies
### 1) Backend
```bash
cd backend
bun install
# Install frontend dependencies
cd frontend
npm install
# Copy environment variables
cp .env.example .env
# Edit .env with your configuration
nano .env
bun run db:migrate
bun run dev
```
### Environment Variables
```env
# Database
DATABASE_URL=postgres://postgres:your_password@localhost:5432/fiscal
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_password
POSTGRES_DB=fiscal
# Backend
PORT=3001
NODE_ENV=production
JWT_SECRET=your-jwt-secret-key-min-32-characters
GITHUB_ID=your_github_oauth_client_id
GITHUB_SECRET=your_github_oauth_client_secret
GOOGLE_ID=your_google_oauth_client_id
GOOGLE_SECRET=your_google_oauth_client_secret
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
```
### Running Locally
### 2) Worker (new terminal)
```bash
# Run database migrations
cd backend
bun run db:migrate
bun run dev:worker
```
# Start backend
cd backend
bun run dev
### 3) Frontend (new terminal)
# Start frontend (new terminal)
```bash
cd frontend
npm install
npm run dev
```
## Deployment via Gitea to Coolify
Frontend: `http://localhost:3000`
Backend: `http://localhost:3001`
Swagger: `http://localhost:3001/swagger`
### 1. Push to Gitea
## Docker Compose
```bash
# Initialize git
cd /data/workspace/fiscal-clone
git init
git add .
git commit -m "feat: Initial Fiscal Clone release"
# Add remote
git remote add gitea https://git.b11studio.xyz/francy51/fiscal-clone.git
# Push to Gitea
git push -u gitea:your-gitea-username main
docker compose up --build
```
### 2. Deploy to Coolify
This starts: `postgres`, `backend`, `worker`, `frontend`.
In Coolify dashboard:
## Coolify
1. **Create Application**
- Type: Docker Compose
- Name: `fiscal-clone`
- Source: Git Repository
- Repository: `git@git.b11studio.xyz:francy51/fiscal-clone.git`
- Branch: `main`
- Build Context: `/`
- Docker Compose File: `docker-compose.yml`
Deploy using the root compose file and configure separate public domains for:
- `frontend` on port `3000`
- `backend` on port `3001`
2. **Configure Domains**
- Frontend: `fiscal.b11studio.xyz`
- Backend API: `api.fiscal.b11studio.xyz`
Use the full guide in `COOLIFY.md`.
3. **Add Environment Variables**
```
DATABASE_URL=postgres://postgres:password@postgres:5432/fiscal
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-password
POSTGRES_DB=fiscal
PORT=3001
JWT_SECRET=your-jwt-secret
NEXT_PUBLIC_API_URL=http://backend:3000
GITHUB_ID=your-github-oauth-id
GITHUB_SECRET=your-github-oauth-secret
GOOGLE_ID=your-google-oauth-id
GOOGLE_SECRET=your-google-oauth-secret
```
Critical variables for Coolify:
- `FRONTEND_URL` = frontend public URL
- `BETTER_AUTH_BASE_URL` = backend public URL
- `NEXT_PUBLIC_API_URL` = backend public URL (build-time in frontend)
4. **Deploy**
## Core API Surface
## API Endpoints
Auth:
- `ALL /api/auth/*` (Better Auth handler)
- `GET /api/me`
### Authentication
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Login
- `POST /api/auth/verify` - Verify JWT token
Watchlist:
- `GET /api/watchlist`
- `POST /api/watchlist`
- `DELETE /api/watchlist/:id`
### SEC Filings
- `GET /api/filings` - Get all filings
- `GET /api/filings/:ticker` - Get filings by ticker
- `POST /api/filings/refresh/:ticker` - Refresh filings
Portfolio:
- `GET /api/portfolio/holdings`
- `POST /api/portfolio/holdings`
- `PATCH /api/portfolio/holdings/:id`
- `DELETE /api/portfolio/holdings/:id`
- `GET /api/portfolio/summary`
- `POST /api/portfolio/refresh-prices` (queues task)
- `POST /api/portfolio/insights/generate` (queues task)
- `GET /api/portfolio/insights/latest`
### Portfolio
- `GET /api/portfolio/:userId` - Get portfolio
- `GET /api/portfolio/:userId/summary` - Get summary
- `POST /api/portfolio` - Add holding
- `PUT /api/portfolio/:id` - Update holding
- `DELETE /api/portfolio/:id` - Delete holding
Filings:
- `GET /api/filings?ticker=&limit=`
- `GET /api/filings/:accessionNumber`
- `POST /api/filings/sync` (queues task)
- `POST /api/filings/:accessionNumber/analyze` (queues task)
### Watchlist
- `GET /api/watchlist/:userId` - Get watchlist
- `POST /api/watchlist` - Add stock
- `DELETE /api/watchlist/:id` - Remove stock
Task tracking:
- `GET /api/tasks`
- `GET /api/tasks/:taskId`
### OpenClaw Integration
- `POST /api/openclaw/notify/filing` - Discord notification
- `POST /api/openclaw/insights/portfolio` - Portfolio analysis
- `POST /api/openclaw/insights/filing` - Filing analysis
## OpenClaw / ZeroClaw Integration
## Database Schema
Set these in `.env`:
### Users
```sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```env
OPENCLAW_BASE_URL=http://localhost:4000
OPENCLAW_API_KEY=...
OPENCLAW_MODEL=zeroclaw
```
### Filings
```sql
CREATE TABLE filings (
id SERIAL PRIMARY KEY,
ticker VARCHAR(10) NOT NULL,
filing_type VARCHAR(20) NOT NULL,
filing_date DATE NOT NULL,
accession_number VARCHAR(40) UNIQUE NOT NULL,
cik VARCHAR(20) NOT NULL,
company_name TEXT NOT NULL,
key_metrics JSONB,
insights TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
The backend expects an OpenAI-compatible `/v1/chat/completions` endpoint.
### Portfolio (with auto-calculations)
```sql
CREATE TABLE portfolio (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
ticker VARCHAR(10) NOT NULL,
shares NUMERIC(20, 4) NOT NULL,
avg_cost NUMERIC(10, 4) NOT NULL,
current_price NUMERIC(10, 4),
current_value NUMERIC(20, 4),
gain_loss NUMERIC(20, 4),
gain_loss_pct NUMERIC(10, 4),
last_updated TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, ticker)
);
```
## Decision Log
### Watchlist
```sql
CREATE TABLE watchlist (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
ticker VARCHAR(10) NOT NULL,
company_name TEXT NOT NULL,
sector VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, ticker)
);
```
## Project Structure
```
fiscal-clone/
├── backend/
│ ├── src/
│ │ ├── index.ts # Main server
│ │ ├── db/
│ │ │ ├── index.ts # Database connection
│ │ │ └── migrate.ts # Database migrations
│ │ ├── routes/
│ │ │ ├── auth.ts # Authentication
│ │ │ ├── filings.ts # SEC filings API
│ │ │ ├── portfolio.ts # Portfolio management
│ │ │ ├── watchlist.ts # Watchlist management
│ │ │ └── openclaw.ts # AI integration
│ │ └── services/
│ │ ├── sec.ts # SEC EDGAR scraper
│ │ └── prices.ts # Yahoo Finance service
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── package.json
├── frontend/
│ ├── app/
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Dashboard
│ │ ├── auth/
│ │ │ ├── signin/page.tsx # Login
│ │ │ └── signup/page.tsx # Registration
│ │ ├── portfolio/page.tsx # Portfolio management
│ │ ├── filings/page.tsx # SEC filings
│ │ └── watchlist/page.tsx # Watchlist
│ ├── lib/
│ │ ├── auth.ts # Auth helpers
│ │ └── utils.ts # Utility functions
│ ├── globals.css
│ ├── tailwind.config.js
│ ├── next.config.js
│ ├── tsconfig.json
│ └── package.json
├── docker-compose.yml # Full stack deployment
└── .env.example # Environment variables template
```
## Features Status
### ✅ Implemented
- [x] User authentication (GitHub, Google, Email/Password)
- [x] SEC EDGAR data scraping
- [x] Key metrics extraction from filings
- [x] Stock holdings tracking
- [x] Real-time price updates (Yahoo Finance)
- [x] Automatic P&L calculations
- [x] Portfolio value summary
- [x] Gain/loss tracking with percentages
- [x] Portfolio allocation pie chart
- [x] Performance line chart
- [x] Watchlist management
- [x] Add/delete holdings
- [x] Add/remove stocks from watchlist
- [x] OpenClaw AI integration endpoints
- [x] Database migrations with triggers
- [x] Full CRUD operations
- [x] Responsive design
- [x] Loading states
- [x] User feedback
- [x] Production-ready Docker configs
### 🚀 Future Enhancements
- [ ] WebSocket for real-time stock prices
- [ ] Two-factor authentication
- [ ] More filing types (S-1, 13D, DEF 14A, etc.)
- [ ] PDF parsing for full filing documents
- [ ] Export functionality (CSV, PDF)
- [ ] Mobile app
- [ ] Advanced analytics and reports
- [ ] Social features (follow portfolios, share holdings)
- [ ] Custom alerts and notifications
- [ ] Tax reporting features
## Security
- Passwords hashed with bcryptjs
- JWT tokens with 30-day expiration
- Protected routes with session checks
- CORS configured for allowed origins
- SQL injection prevention with parameterized queries
- XSS prevention with proper input sanitization
- HTTPS support (via Coolify proxy)
- Environment variables for sensitive data
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
## License
MIT License - see LICENSE file for details
## Support
For issues or questions:
- Open an issue on Gitea
- Check the documentation
- Contact the maintainers
---
**Status:** ✅ Production Ready
**Version:** 1.0.0
**Last Updated:** 2026-02-15
See `docs/REBUILD_DECISIONS.md` for the detailed rationale and tradeoffs behind each major design choice.

View File

@@ -1,14 +1,12 @@
FROM node:20-alpine AS base
FROM node:20-alpine
WORKDIR /app
# Install Bun and update npm
RUN npm install -g bun && npm install -g npm@latest
# Install dependencies
COPY package.json bun.lockb* ./
RUN bun install
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install
# Copy source code
COPY . .
ENV NODE_ENV=production
@@ -16,5 +14,4 @@ ENV PORT=3001
EXPOSE 3001
# Run directly from TypeScript source (Bun can execute TypeScript directly)
CMD ["bun", "run", "src/index.ts"]

View File

@@ -1,43 +1,64 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-fiscal}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-fiscal}']
interval: 5s
timeout: 5s
retries: 10
backend:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: ['sh', '-c', 'bun run src/db/migrate.ts && bun run src/index.ts']
environment:
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
- PORT=3001
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal}
PORT: ${PORT:-3001}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-better-auth-secret-change-me}
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3001}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@example.com>}
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
TASK_HEARTBEAT_SECONDS: ${TASK_HEARTBEAT_SECONDS:-15}
TASK_STALE_SECONDS: ${TASK_STALE_SECONDS:-120}
TASK_MAX_ATTEMPTS: ${TASK_MAX_ATTEMPTS:-3}
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- fiscal
postgres:
image: postgres:16-alpine
worker:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: ['sh', '-c', 'bun run src/db/migrate.ts && bun run src/worker.ts']
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- fiscal
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal}
PORT: ${PORT:-3001}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-better-auth-secret-change-me}
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3001}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@example.com>}
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
TASK_HEARTBEAT_SECONDS: ${TASK_HEARTBEAT_SECONDS:-15}
TASK_STALE_SECONDS: ${TASK_STALE_SECONDS:-120}
TASK_MAX_ATTEMPTS: ${TASK_MAX_ATTEMPTS:-3}
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
networks:
fiscal:
external: true

View File

@@ -1,28 +1,26 @@
{
"name": "fiscal-backend",
"version": "0.1.0",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "bun run --watch src/index.ts",
"dev:worker": "bun run --watch src/worker.ts",
"start": "bun run src/index.ts",
"db:migrate": "bun run src/db/migrate.ts",
"db:seed": "bun run src/db/seed.ts"
"start:worker": "bun run src/worker.ts",
"db:migrate": "bun run src/db/migrate.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/swagger": "^1.3.1",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.18",
"dotenv": "^17.3.1",
"elysia": "^1.4.25",
"jsonwebtoken": "^9.0.3",
"pg": "^8.18.0",
"postgres": "^3.4.8",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/pg": "^8.16.0",
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.10",
"bun-types": "latest"
}
}

View File

@@ -1,34 +1,38 @@
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { betterAuth } from 'better-auth';
import { Pool } from 'pg';
import { env } from './config';
const defaultDatabaseUrl = `postgres://${process.env.POSTGRES_USER || 'postgres'}:${process.env.POSTGRES_PASSWORD || 'postgres'}@${process.env.POSTGRES_HOST || 'localhost'}:5432/${process.env.POSTGRES_DB || 'fiscal'}`;
const defaultFrontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
const trustedOrigins = defaultFrontendUrl
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const pool = new Pool({
connectionString: env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30_000
});
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL || defaultDatabaseUrl,
}),
trustedOrigins,
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_BASE_URL,
database: pool,
trustedOrigins: env.FRONTEND_ORIGINS,
emailAndPassword: {
enabled: true,
autoSignIn: true,
autoSignIn: true
},
user: {
modelName: "users",
modelName: 'users',
additionalFields: {
name: {
type: "string",
required: false,
type: 'string',
required: false
},
},
image: {
type: 'string',
required: false
}
}
},
advanced: {
database: {
generateId: false, // Use PostgreSQL serial for users table
},
},
generateId: false
}
}
});

View File

@@ -1,109 +0,0 @@
import { db } from './db';
async function migrateToBetterAuth() {
console.log('Migrating to Better Auth schema...');
try {
// Add Better Auth columns to users table
await db`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE
`;
await db`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS image TEXT
`;
console.log('✅ Added Better Auth columns to users table');
// Create session table
await db`
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`;
console.log('✅ Created session table');
// Create account table
await db`
CREATE TABLE IF NOT EXISTS account (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
id_token TEXT,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, provider_id, account_id)
)
`;
console.log('✅ Created account table');
// Create verification table
await db`
CREATE TABLE IF NOT EXISTS verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`;
console.log('✅ Created verification table');
// Create indexes
await db`CREATE INDEX IF NOT EXISTS idx_session_user_id ON session(user_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_session_token ON session(token)`;
await db`CREATE INDEX IF NOT EXISTS idx_session_expires_at ON session(expires_at)`;
await db`CREATE INDEX IF NOT EXISTS idx_account_user_id ON account(user_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_account_provider_id ON account(provider_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_verification_identifier ON verification(identifier)`;
await db`CREATE INDEX IF NOT EXISTS idx_verification_expires_at ON verification(expires_at)`;
console.log('✅ Created indexes');
// Migrate existing users to account table for credential auth
await db`
INSERT INTO account (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT
gen_random_uuid(),
id,
id::text,
'credential',
password,
created_at,
updated_at
FROM users
WHERE password IS NOT NULL
ON CONFLICT DO NOTHING
`;
console.log('✅ Migrated existing users to account table');
console.log('✅ Better Auth migration completed!');
process.exit(0);
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}
}
migrateToBetterAuth();

47
backend/src/config.ts Normal file
View File

@@ -0,0 +1,47 @@
import * as dotenv from 'dotenv';
import { z } from 'zod';
dotenv.config();
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3001),
DATABASE_URL: z.string().optional(),
POSTGRES_USER: z.string().default('postgres'),
POSTGRES_PASSWORD: z.string().default('postgres'),
POSTGRES_HOST: z.string().default('localhost'),
POSTGRES_DB: z.string().default('fiscal'),
FRONTEND_URL: z.string().default('http://localhost:3000'),
BETTER_AUTH_SECRET: z.string().min(16).default('local-dev-better-auth-secret-change-me-1234'),
BETTER_AUTH_BASE_URL: z.string().url().default('http://localhost:3001'),
SEC_USER_AGENT: z.string().default('Fiscal Clone <support@fiscal.local>'),
OPENCLAW_BASE_URL: z.preprocess(
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z.string().url().optional()
),
OPENCLAW_API_KEY: z.preprocess(
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z.string().optional()
),
OPENCLAW_MODEL: z.string().default('zeroclaw'),
TASK_HEARTBEAT_SECONDS: z.coerce.number().int().positive().default(15),
TASK_STALE_SECONDS: z.coerce.number().int().positive().default(120),
TASK_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3)
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration', parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
}
const rawEnv = parsed.data;
const databaseUrl = rawEnv.DATABASE_URL
?? `postgres://${rawEnv.POSTGRES_USER}:${rawEnv.POSTGRES_PASSWORD}@${rawEnv.POSTGRES_HOST}:5432/${rawEnv.POSTGRES_DB}`;
export const env = {
...rawEnv,
DATABASE_URL: databaseUrl,
FRONTEND_ORIGINS: rawEnv.FRONTEND_URL.split(',').map((origin) => origin.trim()).filter(Boolean)
};

View File

@@ -1,47 +1,13 @@
import postgres from 'postgres';
import { env } from '../config';
const defaultDatabaseUrl = `postgres://${process.env.POSTGRES_USER || 'postgres'}:${process.env.POSTGRES_PASSWORD || 'postgres'}@${process.env.POSTGRES_HOST || 'localhost'}:5432/${process.env.POSTGRES_DB || 'fiscal'}`;
const sql = postgres(process.env.DATABASE_URL || defaultDatabaseUrl, {
max: 10,
export const db = postgres(env.DATABASE_URL, {
max: 20,
idle_timeout: 20,
connect_timeout: 10
connect_timeout: 10,
prepare: true
});
export const db = sql;
export type Filings = {
id: number;
ticker: string;
filing_type: string;
filing_date: Date;
accession_number: string;
cik: string;
company_name: string;
key_metrics?: any;
insights?: string;
created_at: Date;
};
export type Portfolio = {
id: number;
user_id: string;
ticker: string;
shares: number;
avg_cost: number;
current_price?: number;
current_value?: number;
gain_loss?: number;
gain_loss_pct?: number;
last_updated?: Date;
created_at: Date;
};
export type Watchlist = {
id: number;
user_id: string;
ticker: string;
company_name: string;
sector?: string;
created_at: Date;
};
export async function closeDb() {
await db.end({ timeout: 5 });
}

View File

@@ -1,107 +1,256 @@
import { db } from './index';
async function migrate() {
console.log('Running migrations...');
console.log('Running database migrations...');
await db`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
// Create users table
await db`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
email TEXT UNIQUE NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
name TEXT,
image TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
await db`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`;
await db`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`;
await db`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name = 'password'
) THEN
EXECUTE 'ALTER TABLE users ALTER COLUMN password DROP NOT NULL';
END IF;
END
$$
`;
await db`
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
await db`
CREATE TABLE IF NOT EXISTS account (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
access_token_expires_at TIMESTAMPTZ,
refresh_token_expires_at TIMESTAMPTZ,
scope TEXT,
id_token TEXT,
password TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, provider_id, account_id)
)
`;
await db`
CREATE TABLE IF NOT EXISTS verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
// Create filings table
await db`
CREATE TABLE IF NOT EXISTS filings (
id SERIAL PRIMARY KEY,
ticker VARCHAR(10) NOT NULL,
id BIGSERIAL PRIMARY KEY,
ticker VARCHAR(12) NOT NULL,
filing_type VARCHAR(20) NOT NULL,
filing_date DATE NOT NULL,
accession_number VARCHAR(40) UNIQUE NOT NULL,
accession_number VARCHAR(40) NOT NULL UNIQUE,
cik VARCHAR(20) NOT NULL,
company_name TEXT NOT NULL,
key_metrics JSONB,
insights TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
filing_url TEXT,
metrics JSONB,
analysis JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
// Create portfolio table
await db`ALTER TABLE filings ADD COLUMN IF NOT EXISTS filing_url TEXT`;
await db`ALTER TABLE filings ADD COLUMN IF NOT EXISTS metrics JSONB`;
await db`ALTER TABLE filings ADD COLUMN IF NOT EXISTS analysis JSONB`;
await db`ALTER TABLE filings ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`;
await db`
CREATE TABLE IF NOT EXISTS portfolio (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
ticker VARCHAR(10) NOT NULL,
shares NUMERIC(20, 4) NOT NULL,
avg_cost NUMERIC(10, 4) NOT NULL,
current_price NUMERIC(10, 4),
current_value NUMERIC(20, 4),
gain_loss NUMERIC(20, 4),
gain_loss_pct NUMERIC(10, 4),
last_updated TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, ticker)
)
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'filings'
AND column_name = 'key_metrics'
) THEN
EXECUTE 'UPDATE filings SET metrics = COALESCE(metrics, key_metrics) WHERE metrics IS NULL';
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'filings'
AND column_name = 'insights'
) THEN
EXECUTE $migrate$
UPDATE filings
SET analysis = COALESCE(analysis, jsonb_build_object('legacyInsights', insights))
WHERE analysis IS NULL
AND insights IS NOT NULL
$migrate$;
END IF;
END
$$
`;
// Create watchlist table
await db`
CREATE TABLE IF NOT EXISTS watchlist (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
ticker VARCHAR(10) NOT NULL,
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticker VARCHAR(12) NOT NULL,
company_name TEXT NOT NULL,
sector VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sector VARCHAR(120),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, ticker)
)
`;
// Create indexes
await db`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`;
await db`CREATE INDEX IF NOT EXISTS idx_filings_ticker ON filings(ticker)`;
await db`CREATE INDEX IF NOT EXISTS idx_filings_date ON filings(filing_date DESC)`;
await db`CREATE INDEX IF NOT EXISTS idx_portfolio_user ON portfolio(user_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist(user_id)`;
// Create function to update portfolio prices
await db`
CREATE OR REPLACE FUNCTION update_portfolio_prices()
RETURNS TRIGGER AS $$
CREATE TABLE IF NOT EXISTS holdings (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticker VARCHAR(12) NOT NULL,
shares NUMERIC(20, 4) NOT NULL,
avg_cost NUMERIC(12, 4) NOT NULL,
current_price NUMERIC(12, 4),
market_value NUMERIC(20, 4) GENERATED ALWAYS AS ((COALESCE(current_price, avg_cost) * shares)) STORED,
gain_loss NUMERIC(20, 4) GENERATED ALWAYS AS (((COALESCE(current_price, avg_cost) - avg_cost) * shares)) STORED,
gain_loss_pct NUMERIC(12, 4) GENERATED ALWAYS AS (
CASE
WHEN avg_cost > 0 THEN (((COALESCE(current_price, avg_cost) - avg_cost) / avg_cost) * 100)
ELSE 0
END
) STORED,
last_price_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, ticker)
)
`;
await db`
DO $$
BEGIN
NEW.current_value := NEW.shares * NEW.current_price;
NEW.gain_loss := NEW.current_value - (NEW.shares * NEW.avg_cost);
NEW.gain_loss_pct := CASE
WHEN NEW.avg_cost > 0 THEN ((NEW.current_price - NEW.avg_cost) / NEW.avg_cost) * 100
ELSE 0
END;
NEW.last_updated := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_name = 'portfolio'
) THEN
EXECUTE $migrate$
INSERT INTO holdings (
user_id,
ticker,
shares,
avg_cost,
current_price,
last_price_at,
created_at,
updated_at
)
SELECT
user_id,
ticker,
shares,
avg_cost,
current_price,
last_updated,
created_at,
NOW()
FROM portfolio
ON CONFLICT (user_id, ticker) DO NOTHING
$migrate$;
END IF;
END
$$
`;
// Create trigger
await db`
DROP TRIGGER IF EXISTS update_portfolio_prices_trigger ON portfolio
`;
await db`
CREATE TRIGGER update_portfolio_prices_trigger
BEFORE INSERT OR UPDATE ON portfolio
FOR EACH ROW
EXECUTE FUNCTION update_portfolio_prices()
CREATE TABLE IF NOT EXISTS portfolio_insights (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
model TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
console.log('✅ Migrations completed!');
process.exit(0);
await db`
CREATE TABLE IF NOT EXISTS long_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_type TEXT NOT NULL,
status TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 50,
payload JSONB NOT NULL,
result JSONB,
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
heartbeat_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT long_tasks_status_check CHECK (status IN ('queued', 'running', 'completed', 'failed'))
)
`;
await db`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`;
await db`CREATE INDEX IF NOT EXISTS idx_session_token ON session(token)`;
await db`CREATE INDEX IF NOT EXISTS idx_session_user ON session(user_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_account_user ON account(user_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist(user_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_holdings_user ON holdings(user_id)`;
await db`CREATE INDEX IF NOT EXISTS idx_filings_ticker_date ON filings(ticker, filing_date DESC)`;
await db`CREATE INDEX IF NOT EXISTS idx_filings_accession ON filings(accession_number)`;
await db`CREATE INDEX IF NOT EXISTS idx_portfolio_insights_user ON portfolio_insights(user_id, created_at DESC)`;
await db`CREATE INDEX IF NOT EXISTS idx_long_tasks_status_sched ON long_tasks(status, scheduled_at, priority DESC, created_at)`;
await db`CREATE INDEX IF NOT EXISTS idx_long_tasks_user ON long_tasks(created_by, created_at DESC)`;
console.log('Migrations completed successfully.');
}
migrate().catch(error => {
console.error('❌ Migration failed:', error);
process.exit(1);
});
migrate()
.then(() => process.exit(0))
.catch((error) => {
console.error('Migration failed', error);
process.exit(1);
});

View File

@@ -1,51 +1,58 @@
import { Elysia } from 'elysia';
import { cors } from '@elysiajs/cors';
import { swagger } from '@elysiajs/swagger';
import * as dotenv from 'dotenv';
dotenv.config();
import { env } from './config';
import { db } from './db';
import { filingsRoutes } from './routes/filings';
import { portfolioRoutes } from './routes/portfolio';
import { openclawRoutes } from './routes/openclaw';
import { watchlistRoutes } from './routes/watchlist';
import { betterAuthRoutes } from './routes/better-auth';
import { filingsRoutes } from './routes/filings';
import { meRoutes } from './routes/me';
import { openclawRoutes } from './routes/openclaw';
import { portfolioRoutes } from './routes/portfolio';
import { taskRoutes } from './routes/tasks';
import { watchlistRoutes } from './routes/watchlist';
const frontendOrigin = process.env.FRONTEND_URL || 'http://localhost:3000';
const app = new Elysia({
prefix: '/api'
})
const app = new Elysia({ prefix: '/api' })
.use(cors({
origin: frontendOrigin,
origin: env.FRONTEND_ORIGINS,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
allowedHeaders: ['Content-Type', 'Authorization'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
}))
.use(swagger({
documentation: {
info: {
title: 'Fiscal Clone API',
version: '1.0.0',
description: 'Financial filings and portfolio analytics API'
version: '2.0.0',
description: 'Futuristic fiscal intelligence API with durable jobs and OpenClaw integration.'
}
}
}))
.use(betterAuthRoutes)
.use(filingsRoutes)
.use(portfolioRoutes)
.use(meRoutes)
.use(watchlistRoutes)
.use(portfolioRoutes)
.use(filingsRoutes)
.use(openclawRoutes)
.use(taskRoutes)
.get('/health', async () => {
const queueRows = await db`
SELECT status, COUNT(*)::int AS count
FROM long_tasks
GROUP BY status
`;
// Health check
.get('/health', () => ({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0',
database: 'connected'
}))
return {
status: 'ok',
version: '2.0.0',
timestamp: new Date().toISOString(),
queue: queueRows.reduce<Record<string, number>>((acc, row) => {
acc[row.status] = row.count;
return acc;
}, {})
};
});
.listen(process.env.PORT || 3001);
app.listen(env.PORT);
console.log(`🚀 Backend running on http://localhost:${app.server?.port}`);
console.log(`📚 Swagger docs: http://localhost:${app.server?.port}/swagger`);
console.log(`Fiscal backend listening on http://localhost:${app.server?.port}`);
console.log(`Swagger docs: http://localhost:${app.server?.port}/swagger`);

View File

@@ -1,122 +0,0 @@
import { Elysia, t } from 'elysia';
import * as bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { db } from '../db';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
export const authRoutes = new Elysia({ prefix: '/auth' })
/**
* Register new user
*/
.post('/register', async ({ body }) => {
const { email, password, name } = body;
// Check if user exists
const existing = await db`
SELECT id FROM users WHERE email = ${email}
`;
if (existing.length > 0) {
return { error: 'User already exists' };
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const result = await db`
INSERT INTO users ${db({ email, password: hashedPassword, name })}
RETURNING id, email, name
`;
const user = result[0];
// Generate JWT
const token = jwt.sign(
{ id: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: '30d' }
);
return {
success: true,
user: { id: user.id, email: user.email, name: user.name },
token
};
}, {
body: t.Object({
email: t.String({ format: 'email' }),
password: t.String({ minLength: 8 }),
name: t.String()
})
})
/**
* Login
*/
.post('/login', async ({ body }) => {
const { email, password } = body;
// Find user
const users = await db`
SELECT * FROM users WHERE email = ${email}
`;
if (users.length === 0) {
return { error: 'Invalid credentials' };
}
const user = users[0];
// Verify password
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return { error: 'Invalid credentials' };
}
// Generate JWT
const token = jwt.sign(
{ id: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: '30d' }
);
return {
success: true,
user: { id: user.id, email: user.email, name: user.name },
token
};
}, {
body: t.Object({
email: t.String({ format: 'email' }),
password: t.String()
})
})
/**
* Verify token (for NextAuth credentials provider)
*/
.post('/verify', async ({ body, set }) => {
try {
const decoded = jwt.verify(body.token, JWT_SECRET) as any;
if (!decoded.id || !decoded.email) {
set.status = 401;
return { error: 'Invalid token' };
}
return {
success: true,
user: { id: decoded.id, email: decoded.email }
};
} catch (error) {
set.status = 401;
return { error: 'Invalid token' };
}
}, {
body: t.Object({
token: t.String()
})
});

View File

@@ -1,7 +1,7 @@
import { Elysia } from 'elysia';
import { auth } from '../auth';
export const betterAuthRoutes = new Elysia()
.all('/auth/*', async ({ request }) => {
return auth.handler(request);
export const betterAuthRoutes = new Elysia({ prefix: '/auth' })
.all('/*', async ({ request }) => {
return await auth.handler(request);
});

View File

@@ -0,0 +1,16 @@
import { UnauthorizedError } from '../session';
export function toHttpError(set: { status: number }, error: unknown) {
if (error instanceof UnauthorizedError) {
set.status = 401;
return { error: error.message };
}
if (error instanceof Error) {
set.status = 500;
return { error: error.message };
}
set.status = 500;
return { error: 'Unexpected error' };
}

View File

@@ -1,47 +1,107 @@
import { Elysia, t } from 'elysia';
import { SECScraper } from '../services/sec';
import { db } from '../db';
const sec = new SECScraper();
import { requireSessionUser } from '../session';
import { enqueueTask } from '../tasks/repository';
import { toHttpError } from './error';
export const filingsRoutes = new Elysia({ prefix: '/filings' })
.get('/', async () => {
const filings = await db`
SELECT * FROM filings
ORDER BY filing_date DESC
LIMIT 100
`;
return filings;
})
.get('/', async ({ request, set, query }) => {
try {
await requireSessionUser(request);
const tickerFilter = query.ticker?.trim().toUpperCase();
const limit = Number(query.limit ?? 50);
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 200) : 50;
.get('/:ticker', async ({ params }) => {
const filings = await db`
SELECT * FROM filings
WHERE ticker = ${params.ticker.toUpperCase()}
ORDER BY filing_date DESC
LIMIT 50
`;
return filings;
})
const rows = tickerFilter
? await db`
SELECT *
FROM filings
WHERE ticker = ${tickerFilter}
ORDER BY filing_date DESC, created_at DESC
LIMIT ${safeLimit}
`
: await db`
SELECT *
FROM filings
ORDER BY filing_date DESC, created_at DESC
LIMIT ${safeLimit}
`;
.get('/details/:accessionNumber', async ({ params }) => {
const details = await db`
SELECT * FROM filings
WHERE accession_number = ${params.accessionNumber}
`;
return details[0] || null;
})
.post('/refresh/:ticker', async ({ params }) => {
const newFilings = await sec.searchFilings(params.ticker, 5);
for (const filing of newFilings) {
const metrics = await sec['extractKeyMetrics'](filing);
await db`
INSERT INTO filings ${db(filing, metrics)}
ON CONFLICT (accession_number) DO NOTHING
`;
return { filings: rows };
} catch (error) {
return toHttpError(set, error);
}
}, {
query: t.Object({
ticker: t.Optional(t.String()),
limit: t.Optional(t.Numeric())
})
})
.get('/:accessionNumber', async ({ request, set, params }) => {
try {
await requireSessionUser(request);
const rows = await db`
SELECT *
FROM filings
WHERE accession_number = ${params.accessionNumber}
LIMIT 1
`;
return { success: true, count: newFilings.length };
if (!rows[0]) {
set.status = 404;
return { error: 'Filing not found' };
}
return { filing: rows[0] };
} catch (error) {
return toHttpError(set, error);
}
}, {
params: t.Object({
accessionNumber: t.String({ minLength: 8 })
})
})
.post('/sync', async ({ request, set, body }) => {
try {
const user = await requireSessionUser(request);
const task = await enqueueTask({
taskType: 'sync_filings',
payload: {
ticker: body.ticker.trim().toUpperCase(),
limit: body.limit ?? 20
},
createdBy: user.id,
priority: 90
});
return { task };
} catch (error) {
return toHttpError(set, error);
}
}, {
body: t.Object({
ticker: t.String({ minLength: 1, maxLength: 12 }),
limit: t.Optional(t.Number({ minimum: 1, maximum: 50 }))
})
})
.post('/:accessionNumber/analyze', async ({ request, set, params }) => {
try {
const user = await requireSessionUser(request);
const task = await enqueueTask({
taskType: 'analyze_filing',
payload: {
accessionNumber: params.accessionNumber
},
createdBy: user.id,
priority: 65
});
return { task };
} catch (error) {
return toHttpError(set, error);
}
}, {
params: t.Object({
accessionNumber: t.String({ minLength: 8 })
})
});

13
backend/src/routes/me.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Elysia } from 'elysia';
import { requireSessionUser } from '../session';
import { toHttpError } from './error';
export const meRoutes = new Elysia({ prefix: '/me' })
.get('/', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
return { user };
} catch (error) {
return toHttpError(set, error);
}
});

View File

@@ -1,121 +1,53 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { env } from '../config';
import { requireSessionUser } from '../session';
import { enqueueTask } from '../tasks/repository';
import { toHttpError } from './error';
interface OpenClawMessage {
text: string;
channelId?: string;
}
export const openclawRoutes = new Elysia({ prefix: '/openclaw' })
/**
* Trigger Discord notification for new filing
*/
.post('/notify/filing', async ({ body }) => {
// This endpoint can be called by cron jobs or external webhooks
// to send Discord notifications about new filings
const message = `📄 **New SEC Filing**
**Ticker:** ${body.ticker}
**Type:** ${body.filingType}
**Date:** ${body.filingDate}
View details: ${body.url}`;
// In production, this would send to Discord via webhook
// For now, we just log it
console.log('[DISCORD]', message);
return { success: true, message };
}, {
body: t.Object({
ticker: t.String(),
filingType: t.String(),
filingDate: t.String(),
url: t.String()
})
})
/**
* Get AI insights for portfolio
*/
.post('/insights/portfolio', async ({ body }) => {
const holdings = await db`
SELECT * FROM portfolio
WHERE user_id = ${body.userId}
`;
// Generate AI analysis
const prompt = `
Analyze this portfolio:
${JSON.stringify(holdings, null, 2)}
Provide:
1. Overall portfolio health assessment
2. Risk analysis
3. Top 3 recommendations
4. Any concerning patterns
`;
// This would call OpenClaw's AI
// For now, return placeholder
return {
health: 'moderate',
risk: 'medium',
recommendations: [
'Consider diversifying sector exposure',
'Review underperforming positions',
'Rebalance portfolio'
],
analysis: 'Portfolio shows mixed performance with some concentration risk.'
};
}, {
body: t.Object({
userId: t.String()
})
})
/**
* Get AI insights for a specific filing
*/
.post('/insights/filing', async ({ body }) => {
const filing = await db`
SELECT * FROM filings
WHERE accession_number = ${body.accessionNumber}
`;
if (!filing) {
return { error: 'Filing not found' };
export const openclawRoutes = new Elysia({ prefix: '/ai' })
.get('/status', async ({ request, set }) => {
try {
await requireSessionUser(request);
return {
configured: Boolean(env.OPENCLAW_BASE_URL && env.OPENCLAW_API_KEY),
baseUrl: env.OPENCLAW_BASE_URL ?? null,
model: env.OPENCLAW_MODEL
};
} catch (error) {
return toHttpError(set, error);
}
})
.post('/portfolio-insights', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const task = await enqueueTask({
taskType: 'portfolio_insights',
payload: { userId: user.id },
createdBy: user.id,
priority: 70
});
const prompt = `
Analyze this SEC filing:
return { task };
} catch (error) {
return toHttpError(set, error);
}
})
.post('/filing-insights', async ({ request, set, body }) => {
try {
const user = await requireSessionUser(request);
const task = await enqueueTask({
taskType: 'analyze_filing',
payload: { accessionNumber: body.accessionNumber },
createdBy: user.id,
priority: 65
});
**Company:** ${filing.company_name}
**Ticker:** ${filing.ticker}
**Type:** ${filing.filing_type}
**Date:** ${filing.filing_date}
**Key Metrics:**
${JSON.stringify(filing.key_metrics, null, 2)}
Provide key insights and any red flags.
`;
// Store insights
await db`
UPDATE filings
SET insights = ${prompt}
WHERE accession_number = ${body.accessionNumber}
`;
return {
insights: 'Analysis saved',
filing
};
return { task };
} catch (error) {
return toHttpError(set, error);
}
}, {
body: t.Object({
accessionNumber: t.String()
accessionNumber: t.String({ minLength: 8 })
})
});

View File

@@ -1,65 +1,197 @@
import { Elysia, t } from 'elysia';
import { db, type Portfolio } from '../db';
import { db } from '../db';
import { requireSessionUser } from '../session';
import { enqueueTask } from '../tasks/repository';
import { toHttpError } from './error';
export const portfolioRoutes = new Elysia({ prefix: '/portfolio' })
.get('/:userId', async ({ params }) => {
const holdings = await db`
SELECT * FROM portfolio
WHERE user_id = ${params.userId}
ORDER BY ticker
`;
.get('/holdings', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const holdings = await db`
SELECT
id,
user_id,
ticker,
shares,
avg_cost,
current_price,
market_value,
gain_loss,
gain_loss_pct,
last_price_at,
created_at,
updated_at
FROM holdings
WHERE user_id = ${user.id}
ORDER BY market_value DESC, ticker ASC
`;
return holdings;
return { holdings };
} catch (error) {
return toHttpError(set, error);
}
})
.post('/holdings', async ({ request, set, body }) => {
try {
const user = await requireSessionUser(request);
const ticker = body.ticker.trim().toUpperCase();
.post('/', async ({ body }) => {
const result = await db`
INSERT INTO portfolio ${db(body as Portfolio)}
ON CONFLICT (user_id, ticker)
DO UPDATE SET
shares = EXCLUDED.shares,
avg_cost = EXCLUDED.avg_cost,
current_price = EXCLUDED.current_price
RETURNING *
`;
const rows = await db`
INSERT INTO holdings (
user_id,
ticker,
shares,
avg_cost,
current_price
) VALUES (
${user.id},
${ticker},
${body.shares},
${body.avgCost},
${body.currentPrice ?? null}
)
ON CONFLICT (user_id, ticker)
DO UPDATE SET
shares = EXCLUDED.shares,
avg_cost = EXCLUDED.avg_cost,
current_price = COALESCE(EXCLUDED.current_price, holdings.current_price),
updated_at = NOW()
RETURNING *
`;
return result[0];
return { holding: rows[0] };
} catch (error) {
return toHttpError(set, error);
}
}, {
body: t.Object({
user_id: t.String(),
ticker: t.String(),
shares: t.Number(),
avg_cost: t.Number(),
current_price: t.Optional(t.Number())
ticker: t.String({ minLength: 1, maxLength: 12 }),
shares: t.Number({ minimum: 0.0001 }),
avgCost: t.Number({ minimum: 0.0001 }),
currentPrice: t.Optional(t.Number({ minimum: 0 }))
})
})
.patch('/holdings/:id', async ({ request, set, params, body }) => {
try {
const user = await requireSessionUser(request);
const rows = await db`
UPDATE holdings
SET
shares = COALESCE(${body.shares ?? null}, shares),
avg_cost = COALESCE(${body.avgCost ?? null}, avg_cost),
current_price = COALESCE(${body.currentPrice ?? null}, current_price),
updated_at = NOW()
WHERE id = ${params.id}
AND user_id = ${user.id}
RETURNING *
`;
.put('/:id', async ({ params, body }) => {
const result = await db`
UPDATE portfolio
SET ${db(body)}
WHERE id = ${params.id}
RETURNING *
`;
if (!rows[0]) {
set.status = 404;
return { error: 'Holding not found' };
}
return result[0] || null;
return { holding: rows[0] };
} catch (error) {
return toHttpError(set, error);
}
}, {
params: t.Object({
id: t.Numeric()
}),
body: t.Object({
shares: t.Optional(t.Number({ minimum: 0.0001 })),
avgCost: t.Optional(t.Number({ minimum: 0.0001 })),
currentPrice: t.Optional(t.Number({ minimum: 0 }))
})
})
.delete('/holdings/:id', async ({ request, set, params }) => {
try {
const user = await requireSessionUser(request);
const rows = await db`
DELETE FROM holdings
WHERE id = ${params.id}
AND user_id = ${user.id}
RETURNING id
`;
.delete('/:id', async ({ params }) => {
await db`DELETE FROM portfolio WHERE id = ${params.id}`;
return { success: true };
if (!rows[0]) {
set.status = 404;
return { error: 'Holding not found' };
}
return { success: true };
} catch (error) {
return toHttpError(set, error);
}
}, {
params: t.Object({
id: t.Numeric()
})
})
.get('/summary', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const rows = await db`
SELECT
COUNT(*)::int AS positions,
COALESCE(SUM(market_value), 0)::numeric AS total_value,
COALESCE(SUM(gain_loss), 0)::numeric AS total_gain_loss,
COALESCE(SUM(shares * avg_cost), 0)::numeric AS total_cost_basis,
COALESCE(AVG(gain_loss_pct), 0)::numeric AS avg_return_pct
FROM holdings
WHERE user_id = ${user.id}
`;
.get('/:userId/summary', async ({ params }) => {
const summary = await db`
SELECT
COUNT(*) as total_positions,
COALESCE(SUM(current_value), 0) as total_value,
COALESCE(SUM(gain_loss), 0) as total_gain_loss,
COALESCE(SUM(current_value) - SUM(shares * avg_cost), 0) as cost_basis
FROM portfolio
WHERE user_id = ${params.userId}
`;
return { summary: rows[0] };
} catch (error) {
return toHttpError(set, error);
}
})
.post('/refresh-prices', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const task = await enqueueTask({
taskType: 'refresh_prices',
payload: { userId: user.id },
createdBy: user.id,
priority: 80
});
return summary[0];
return { task };
} catch (error) {
return toHttpError(set, error);
}
})
.post('/insights/generate', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const task = await enqueueTask({
taskType: 'portfolio_insights',
payload: { userId: user.id },
createdBy: user.id,
priority: 70
});
return { task };
} catch (error) {
return toHttpError(set, error);
}
})
.get('/insights/latest', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const rows = await db`
SELECT id, user_id, provider, model, content, created_at
FROM portfolio_insights
WHERE user_id = ${user.id}
ORDER BY created_at DESC
LIMIT 1
`;
return { insight: rows[0] ?? null };
} catch (error) {
return toHttpError(set, error);
}
});

View File

@@ -0,0 +1,40 @@
import { Elysia, t } from 'elysia';
import { requireSessionUser } from '../session';
import { getTaskById, listRecentTasks } from '../tasks/repository';
import { toHttpError } from './error';
export const taskRoutes = new Elysia({ prefix: '/tasks' })
.get('/', async ({ request, set, query }) => {
try {
const user = await requireSessionUser(request);
const limit = Number(query.limit ?? 20);
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 50) : 20;
const tasks = await listRecentTasks(user.id, safeLimit);
return { tasks };
} catch (error) {
return toHttpError(set, error);
}
}, {
query: t.Object({
limit: t.Optional(t.Numeric())
})
})
.get('/:taskId', async ({ request, set, params }) => {
try {
const user = await requireSessionUser(request);
const task = await getTaskById(params.taskId, user.id);
if (!task) {
set.status = 404;
return { error: 'Task not found' };
}
return { task };
} catch (error) {
return toHttpError(set, error);
}
}, {
params: t.Object({
taskId: t.String()
})
});

View File

@@ -1,35 +1,80 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { requireSessionUser } from '../session';
import { toHttpError } from './error';
export const watchlistRoutes = new Elysia({ prefix: '/watchlist' })
.get('/:userId', async ({ params }) => {
const watchlist = await db`
SELECT * FROM watchlist
WHERE user_id = ${params.userId}
ORDER BY created_at DESC
`;
.get('/', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const watchlist = await db`
SELECT id, user_id, ticker, company_name, sector, created_at
FROM watchlist
WHERE user_id = ${user.id}
ORDER BY created_at DESC
`;
return watchlist;
return { items: watchlist };
} catch (error) {
return toHttpError(set, error);
}
})
.post('/', async ({ request, set, body }) => {
try {
const user = await requireSessionUser(request);
const ticker = body.ticker.trim().toUpperCase();
.post('/', async ({ body }) => {
const result = await db`
INSERT INTO watchlist ${db(body)}
ON CONFLICT (user_id, ticker) DO NOTHING
RETURNING *
`;
const rows = await db`
INSERT INTO watchlist (
user_id,
ticker,
company_name,
sector
) VALUES (
${user.id},
${ticker},
${body.companyName.trim()},
${body.sector?.trim() || null}
)
ON CONFLICT (user_id, ticker)
DO UPDATE SET
company_name = EXCLUDED.company_name,
sector = EXCLUDED.sector
RETURNING *
`;
return result[0];
return { item: rows[0] };
} catch (error) {
return toHttpError(set, error);
}
}, {
body: t.Object({
user_id: t.String(),
ticker: t.String(),
company_name: t.String(),
sector: t.Optional(t.String())
ticker: t.String({ minLength: 1, maxLength: 12 }),
companyName: t.String({ minLength: 1, maxLength: 200 }),
sector: t.Optional(t.String({ maxLength: 120 }))
})
})
.delete('/:id', async ({ request, set, params }) => {
try {
const user = await requireSessionUser(request);
const rows = await db`
DELETE FROM watchlist
WHERE id = ${params.id}
AND user_id = ${user.id}
RETURNING id
`;
.delete('/:id', async ({ params }) => {
await db`DELETE FROM watchlist WHERE id = ${params.id}`;
return { success: true };
if (!rows[0]) {
set.status = 404;
return { error: 'Watchlist item not found' };
}
return { success: true };
} catch (error) {
return toHttpError(set, error);
}
}, {
params: t.Object({
id: t.Numeric()
})
});

View File

@@ -0,0 +1,61 @@
import { env } from '../config';
type ChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
};
export class OpenClawService {
isConfigured() {
return Boolean(env.OPENCLAW_BASE_URL && env.OPENCLAW_API_KEY);
}
async runAnalysis(prompt: string, systemPrompt?: string) {
if (!this.isConfigured()) {
return {
provider: 'local-fallback',
model: env.OPENCLAW_MODEL,
text: 'OpenClaw/ZeroClaw is not configured. Set OPENCLAW_BASE_URL and OPENCLAW_API_KEY to enable live AI analysis.'
};
}
const response = await fetch(`${env.OPENCLAW_BASE_URL}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OPENCLAW_API_KEY}`
},
body: JSON.stringify({
model: env.OPENCLAW_MODEL,
temperature: 0.2,
messages: [
systemPrompt
? { role: 'system', content: systemPrompt }
: null,
{ role: 'user', content: prompt }
].filter(Boolean)
})
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenClaw request failed (${response.status}): ${body.slice(0, 200)}`);
}
const payload = await response.json() as ChatCompletionResponse;
const text = payload.choices?.[0]?.message?.content?.trim();
if (!text) {
throw new Error('OpenClaw returned an empty response');
}
return {
provider: 'openclaw',
model: env.OPENCLAW_MODEL,
text
};
}
}

View File

@@ -1,116 +1,72 @@
import { db } from '../db';
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
export class PriceService {
private baseUrl = 'https://query1.finance.yahoo.com/v8/finance/chart';
async getQuote(ticker: string): Promise<number | null> {
const normalizedTicker = ticker.trim().toUpperCase();
/**
* Get current price for a ticker
*/
async getPrice(ticker: string): Promise<number | null> {
try {
const response = await fetch(
`${this.baseUrl}/${ticker}?interval=1d&range=1d`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/1.0)'
}
const response = await fetch(`${YAHOO_BASE}/${normalizedTicker}?interval=1d&range=1d`, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/2.0)'
}
);
});
if (!response.ok) return null;
const data = await response.json();
const result = data.chart?.result?.[0];
if (!result?.meta?.regularMarketPrice) {
if (!response.ok) {
return null;
}
return result.meta.regularMarketPrice;
} catch (error) {
console.error(`Error fetching price for ${ticker}:`, error);
const payload = await response.json() as {
chart?: {
result?: Array<{ meta?: { regularMarketPrice?: number } }>;
};
};
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
return typeof price === 'number' ? price : null;
} catch {
return null;
}
}
/**
* Get historical prices
*/
async getHistoricalPrices(ticker: string, period: string = '1y'): Promise<Array<{ date: string, price: number }>> {
try {
const response = await fetch(
`${this.baseUrl}/${ticker}?interval=1d&range=${period}`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/1.0)'
}
}
);
async refreshHoldingsPrices(userId?: number) {
const holdings = userId
? await db`SELECT DISTINCT ticker FROM holdings WHERE user_id = ${userId}`
: await db`SELECT DISTINCT ticker FROM holdings`;
if (!response.ok) return [];
let updatedCount = 0;
const data = await response.json();
const result = data.chart?.result?.[0];
for (const holding of holdings) {
const price = await this.getQuote(holding.ticker);
if (!result?.timestamp || !result?.indicators?.quote?.[0]?.close) {
return [];
if (price === null) {
continue;
}
const timestamps = result.timestamp;
const closes = result.indicators.quote[0].close;
return timestamps.map((ts: number, i: number) => ({
date: new Date(ts * 1000).toISOString(),
price: closes[i]
})).filter((p: any) => p.price !== null);
} catch (error) {
console.error(`Error fetching historical prices for ${ticker}:`, error);
return [];
}
}
/**
* Update all portfolio prices
*/
async updateAllPrices(db: any) {
const holdings = await db`
SELECT DISTINCT ticker FROM portfolio
`;
let updated = 0;
for (const { ticker } of holdings) {
const price = await this.getPrice(ticker);
if (price) {
if (userId) {
await db`
UPDATE portfolio
SET current_price = ${price}
WHERE ticker = ${ticker}
UPDATE holdings
SET current_price = ${price}, last_price_at = NOW(), updated_at = NOW()
WHERE user_id = ${userId} AND ticker = ${holding.ticker}
`;
} else {
await db`
UPDATE holdings
SET current_price = ${price}, last_price_at = NOW(), updated_at = NOW()
WHERE ticker = ${holding.ticker}
`;
updated++;
}
// Rate limiting
await new Promise(resolve => setTimeout(resolve, 100));
updatedCount += 1;
await Bun.sleep(120);
}
console.log(`Updated ${updated} stock prices`);
}
/**
* Get quote for multiple tickers
*/
async getQuotes(tickers: string[]): Promise<Record<string, number>> {
const quotes: Record<string, number> = {};
await Promise.all(
tickers.map(async ticker => {
const price = await this.getPrice(ticker);
if (price) {
quotes[ticker] = price;
}
})
);
return quotes;
return {
updatedCount,
totalTickers: holdings.length
};
}
}

View File

@@ -1,162 +1,208 @@
import { type Filings } from '../db';
import { env } from '../config';
import type { FilingMetrics, FilingType } from '../types';
export class SECScraper {
private baseUrl = 'https://www.sec.gov';
private userAgent = 'Fiscal Clone (contact@example.com)';
type TickerDirectoryRecord = {
cik_str: number;
ticker: string;
title: string;
};
/**
* Search SEC filings by ticker
*/
async searchFilings(ticker: string, count = 20): Promise<Filings[]> {
const cik = await this.getCIK(ticker);
type RecentFilingsPayload = {
filings?: {
recent?: {
accessionNumber?: string[];
filingDate?: string[];
form?: string[];
primaryDocument?: string[];
};
};
cik?: string;
name?: string;
};
const response = await fetch(
`https://data.sec.gov/submissions/CIK${cik.padStart(10, '0')}.json`,
{
headers: {
'User-Agent': this.userAgent
}
type CompanyFactsPayload = {
facts?: {
'us-gaap'?: Record<string, { units?: Record<string, Array<{ val?: number; end?: string; filed?: string }>> }>;
};
};
export type SecFiling = {
ticker: string;
cik: string;
companyName: string;
filingType: FilingType;
filingDate: string;
accessionNumber: string;
filingUrl: string | null;
};
const SUPPORTED_FORMS: FilingType[] = ['10-K', '10-Q', '8-K'];
const TICKER_CACHE_TTL_MS = 1000 * 60 * 60 * 24;
const FACTS_CACHE_TTL_MS = 1000 * 60 * 10;
export class SecService {
private tickerCache: Map<string, TickerDirectoryRecord> = new Map();
private tickerCacheLoadedAt = 0;
private factsCache: Map<string, { loadedAt: number; metrics: FilingMetrics }> = new Map();
private async fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
'User-Agent': env.SEC_USER_AGENT,
Accept: 'application/json'
}
);
});
if (!response.ok) {
throw new Error(`SEC API error: ${response.status}`);
throw new Error(`SEC request failed (${response.status}) for ${url}`);
}
const data = await response.json();
const filings = data.filings?.recent || [];
const filteredFilings = filings
.filter((f: any) =>
['10-K', '10-Q', '8-K'].includes(f.form)
)
.slice(0, count)
.map((f: any) => ({
ticker,
filing_type: f.form,
filing_date: new Date(f.filingDate),
accession_number: f.accessionNumber,
cik: data.cik,
company_name: data.name || ticker,
}));
return filteredFilings;
return await response.json() as T;
}
/**
* Check for new filings and save to database
*/
async checkNewFilings(db: any) {
const tickers = await db`
SELECT DISTINCT ticker FROM watchlist
`;
private async ensureTickerCache() {
const isFresh = Date.now() - this.tickerCacheLoadedAt < TICKER_CACHE_TTL_MS;
console.log(`Checking filings for ${tickers.length} tickers...`);
for (const { ticker } of tickers) {
try {
const latest = await db`
SELECT accession_number FROM filings
WHERE ticker = ${ticker}
ORDER BY filing_date DESC
LIMIT 1
`;
const filings = await this.searchFilings(ticker, 10);
const newFilings = filings.filter(
f => !latest.some((l: any) => l.accession_number === f.accession_number)
);
if (newFilings.length > 0) {
console.log(`Found ${newFilings.length} new filings for ${ticker}`);
for (const filing of newFilings) {
const metrics = await this.extractKeyMetrics(filing);
await db`
INSERT INTO filings ${db(filing, metrics)}
ON CONFLICT (accession_number) DO NOTHING
`;
}
}
} catch (error) {
console.error(`Error checking filings for ${ticker}:`, error);
}
}
}
/**
* Get CIK for a ticker
*/
private async getCIK(ticker: string): Promise<string> {
const response = await fetch(
`https://www.sec.gov/files/company_tickers.json`
);
if (!response.ok) {
throw new Error('Failed to get company tickers');
if (isFresh && this.tickerCache.size > 0) {
return;
}
const data = await response.json();
const companies = data.data;
const payload = await this.fetchJson<Record<string, TickerDirectoryRecord>>('https://www.sec.gov/files/company_tickers.json');
const nextCache = new Map<string, TickerDirectoryRecord>();
for (const [cik, company] of Object.entries(companies)) {
if (company.ticker === ticker.toUpperCase()) {
return cik;
}
for (const record of Object.values(payload)) {
nextCache.set(record.ticker.toUpperCase(), record);
}
throw new Error(`Ticker ${ticker} not found`);
this.tickerCache = nextCache;
this.tickerCacheLoadedAt = Date.now();
}
/**
* Extract key metrics from filing
*/
async extractKeyMetrics(filing: any): Promise<any> {
try {
const filingUrl = `${this.baseUrl}/Archives/${filing.accession_number.replace(/-/g, '')}/${filing.accession_number}-index.htm`;
async resolveTicker(ticker: string) {
await this.ensureTickerCache();
const response = await fetch(filingUrl, {
headers: { 'User-Agent': this.userAgent }
});
const normalizedTicker = ticker.trim().toUpperCase();
const record = this.tickerCache.get(normalizedTicker);
if (!response.ok) return null;
const html = await response.text();
// Extract key financial metrics from XBRL
const metrics = {
revenue: this.extractMetric(html, 'Revenues'),
netIncome: this.extractMetric(html, 'NetIncomeLoss'),
totalAssets: this.extractMetric(html, 'Assets'),
cash: this.extractMetric(html, 'CashAndCashEquivalentsAtCarryingValue'),
debt: this.extractMetric(html, 'LongTermDebt')
};
return metrics;
} catch (error) {
console.error('Error extracting metrics:', error);
return null;
if (!record) {
throw new Error(`Ticker ${normalizedTicker} was not found in SEC directory`);
}
}
/**
* Extract a specific metric from XBRL data
*/
private extractMetric(html: string, metricName: string): number | null {
const regex = new RegExp(`<ix:nonFraction[^>]*name="[^"]*${metricName}[^"]*"[^>]*>([^<]+)<`, 'i');
const match = html.match(regex);
return match ? parseFloat(match[1].replace(/,/g, '')) : null;
}
/**
* Get filing details by accession number
*/
async getFilingDetails(accessionNumber: string) {
const filingUrl = `${this.baseUrl}/Archives/${accessionNumber.replace(/-/g, '')}/${accessionNumber}-index.htm`;
return {
filing_url: filingUrl
ticker: normalizedTicker,
cik: String(record.cik_str),
companyName: record.title
};
}
async fetchRecentFilings(ticker: string, limit = 20): Promise<SecFiling[]> {
const company = await this.resolveTicker(ticker);
const cikPadded = company.cik.padStart(10, '0');
const payload = await this.fetchJson<RecentFilingsPayload>(`https://data.sec.gov/submissions/CIK${cikPadded}.json`);
const recent = payload.filings?.recent;
if (!recent) {
return [];
}
const forms = recent.form ?? [];
const accessionNumbers = recent.accessionNumber ?? [];
const filingDates = recent.filingDate ?? [];
const primaryDocuments = recent.primaryDocument ?? [];
const filings: SecFiling[] = [];
for (let i = 0; i < forms.length; i += 1) {
const filingType = forms[i] as FilingType;
if (!SUPPORTED_FORMS.includes(filingType)) {
continue;
}
const accessionNumber = accessionNumbers[i];
if (!accessionNumber) {
continue;
}
const compactAccession = accessionNumber.replace(/-/g, '');
const documentName = primaryDocuments[i];
const filingUrl = documentName
? `https://www.sec.gov/Archives/edgar/data/${Number(company.cik)}/${compactAccession}/${documentName}`
: null;
filings.push({
ticker: company.ticker,
cik: company.cik,
companyName: payload.name ?? company.companyName,
filingType,
filingDate: filingDates[i] ?? new Date().toISOString().slice(0, 10),
accessionNumber,
filingUrl
});
if (filings.length >= limit) {
break;
}
}
return filings;
}
private pickLatestFact(payload: CompanyFactsPayload, tag: string): number | null {
const unitCollections = payload.facts?.['us-gaap']?.[tag]?.units;
if (!unitCollections) {
return null;
}
const preferredUnits = ['USD', 'USD/shares'];
for (const unit of preferredUnits) {
const series = unitCollections[unit];
if (!series?.length) {
continue;
}
const best = [...series]
.filter((item) => typeof item.val === 'number')
.sort((a, b) => {
const aDate = Date.parse(a.filed ?? a.end ?? '1970-01-01');
const bDate = Date.parse(b.filed ?? b.end ?? '1970-01-01');
return bDate - aDate;
})[0];
if (best?.val !== undefined) {
return best.val;
}
}
return null;
}
async fetchMetrics(cik: string): Promise<FilingMetrics> {
const normalized = cik.padStart(10, '0');
const cached = this.factsCache.get(normalized);
if (cached && Date.now() - cached.loadedAt < FACTS_CACHE_TTL_MS) {
return cached.metrics;
}
const payload = await this.fetchJson<CompanyFactsPayload>(`https://data.sec.gov/api/xbrl/companyfacts/CIK${normalized}.json`);
const metrics: FilingMetrics = {
revenue: this.pickLatestFact(payload, 'Revenues'),
netIncome: this.pickLatestFact(payload, 'NetIncomeLoss'),
totalAssets: this.pickLatestFact(payload, 'Assets'),
cash: this.pickLatestFact(payload, 'CashAndCashEquivalentsAtCarryingValue'),
debt: this.pickLatestFact(payload, 'LongTermDebt')
};
this.factsCache.set(normalized, {
loadedAt: Date.now(),
metrics
});
return metrics;
}
}

30
backend/src/session.ts Normal file
View File

@@ -0,0 +1,30 @@
import { auth } from './auth';
import type { SessionUser } from './types';
export class UnauthorizedError extends Error {
constructor(message = 'Authentication required') {
super(message);
this.name = 'UnauthorizedError';
}
}
export async function requireSessionUser(request: Request): Promise<SessionUser> {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user?.id) {
throw new UnauthorizedError();
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) {
throw new UnauthorizedError('Invalid session user id');
}
return {
id: userId,
email: session.user.email,
name: session.user.name ?? null,
image: session.user.image ?? null
};
}

View File

@@ -0,0 +1,201 @@
import { z } from 'zod';
import { db } from '../db';
import { OpenClawService } from '../services/openclaw';
import { PriceService } from '../services/prices';
import { SecService } from '../services/sec';
import type { LongTaskRecord, TaskType } from '../types';
const secService = new SecService();
const priceService = new PriceService();
const openClawService = new OpenClawService();
const syncFilingsPayload = z.object({
ticker: z.string().min(1),
limit: z.number().int().positive().max(50).default(20)
});
const refreshPricesPayload = z.object({
userId: z.number().int().positive().optional()
});
const analyzeFilingPayload = z.object({
accessionNumber: z.string().min(8)
});
const portfolioInsightsPayload = z.object({
userId: z.number().int().positive()
});
async function processSyncFilings(task: LongTaskRecord) {
const { ticker, limit } = syncFilingsPayload.parse(task.payload);
const filings = await secService.fetchRecentFilings(ticker, limit);
const metrics = filings.length > 0
? await secService.fetchMetrics(filings[0].cik)
: null;
let touched = 0;
for (const filing of filings) {
await db`
INSERT INTO filings (
ticker,
filing_type,
filing_date,
accession_number,
cik,
company_name,
filing_url,
metrics,
updated_at
) VALUES (
${filing.ticker},
${filing.filingType},
${filing.filingDate},
${filing.accessionNumber},
${filing.cik},
${filing.companyName},
${filing.filingUrl},
${metrics},
NOW()
)
ON CONFLICT (accession_number)
DO UPDATE SET
filing_type = EXCLUDED.filing_type,
filing_date = EXCLUDED.filing_date,
filing_url = EXCLUDED.filing_url,
metrics = COALESCE(EXCLUDED.metrics, filings.metrics),
updated_at = NOW()
`;
touched += 1;
}
return {
ticker: ticker.toUpperCase(),
filingsFetched: filings.length,
recordsUpserted: touched,
metrics
};
}
async function processRefreshPrices(task: LongTaskRecord) {
const { userId } = refreshPricesPayload.parse(task.payload);
const result = await priceService.refreshHoldingsPrices(userId);
return {
scope: userId ? `user:${userId}` : 'global',
...result
};
}
async function processAnalyzeFiling(task: LongTaskRecord) {
const { accessionNumber } = analyzeFilingPayload.parse(task.payload);
const rows = await db`
SELECT *
FROM filings
WHERE accession_number = ${accessionNumber}
LIMIT 1
`;
const filing = rows[0];
if (!filing) {
throw new Error(`Filing ${accessionNumber} was not found`);
}
const prompt = [
'You are a fiscal research assistant focused on regulatory signals.',
`Analyze this SEC filing from ${filing.company_name} (${filing.ticker}).`,
`Form: ${filing.filing_type}`,
`Filed: ${filing.filing_date}`,
`Metrics JSON: ${JSON.stringify(filing.metrics ?? {})}`,
'Return concise sections: Thesis, Red Flags, Follow-up Questions, Portfolio Impact.'
].join('\n');
const analysis = await openClawService.runAnalysis(prompt, 'Use concise institutional analyst language.');
await db`
UPDATE filings
SET analysis = ${analysis},
updated_at = NOW()
WHERE accession_number = ${accessionNumber}
`;
return {
accessionNumber,
analysis
};
}
async function processPortfolioInsights(task: LongTaskRecord) {
const { userId } = portfolioInsightsPayload.parse(task.payload);
const holdings = await db`
SELECT
ticker,
shares,
avg_cost,
current_price,
market_value,
gain_loss,
gain_loss_pct
FROM holdings
WHERE user_id = ${userId}
ORDER BY market_value DESC
`;
const summaryRows = await db`
SELECT
COUNT(*)::int AS positions,
COALESCE(SUM(market_value), 0)::numeric AS total_value,
COALESCE(SUM(gain_loss), 0)::numeric AS total_gain_loss,
COALESCE(AVG(gain_loss_pct), 0)::numeric AS avg_return_pct
FROM holdings
WHERE user_id = ${userId}
`;
const summary = summaryRows[0] ?? {
positions: 0,
total_value: 0,
total_gain_loss: 0,
avg_return_pct: 0
};
const prompt = [
'Generate portfolio intelligence with actionable recommendations.',
`Portfolio summary: ${JSON.stringify(summary)}`,
`Holdings: ${JSON.stringify(holdings)}`,
'Respond with: 1) Portfolio health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.'
].join('\n');
const insight = await openClawService.runAnalysis(prompt, 'Act as a risk-aware buy-side analyst.');
await db`
INSERT INTO portfolio_insights (user_id, model, provider, content)
VALUES (${userId}, ${insight.model}, ${insight.provider}, ${insight.text})
`;
return {
userId,
summary,
insight
};
}
const processors: Record<TaskType, (task: LongTaskRecord) => Promise<Record<string, unknown>>> = {
sync_filings: processSyncFilings,
refresh_prices: processRefreshPrices,
analyze_filing: processAnalyzeFiling,
portfolio_insights: processPortfolioInsights
};
export async function processTask(task: LongTaskRecord) {
const processor = processors[task.task_type];
if (!processor) {
throw new Error(`No processor registered for task ${task.task_type}`);
}
return await processor(task);
}

View File

@@ -0,0 +1,168 @@
import { db } from '../db';
import { env } from '../config';
import type { LongTaskRecord, TaskType } from '../types';
type EnqueueTaskInput = {
taskType: TaskType;
payload: Record<string, unknown>;
createdBy?: number;
priority?: number;
scheduledAt?: Date;
maxAttempts?: number;
};
export async function enqueueTask(input: EnqueueTaskInput) {
const task = await db<LongTaskRecord[]>`
INSERT INTO long_tasks (
task_type,
status,
priority,
payload,
max_attempts,
scheduled_at,
created_by
) VALUES (
${input.taskType},
'queued',
${input.priority ?? 50},
${input.payload},
${input.maxAttempts ?? env.TASK_MAX_ATTEMPTS},
${input.scheduledAt ?? new Date()},
${input.createdBy ?? null}
)
RETURNING *
`;
return task[0];
}
export async function getTaskById(taskId: string, userId?: number) {
const rows = userId
? await db<LongTaskRecord[]>`
SELECT *
FROM long_tasks
WHERE id = ${taskId}
AND (created_by IS NULL OR created_by = ${userId})
LIMIT 1
`
: await db<LongTaskRecord[]>`
SELECT *
FROM long_tasks
WHERE id = ${taskId}
LIMIT 1
`;
return rows[0] ?? null;
}
export async function listRecentTasks(userId: number, limit = 20) {
return await db<LongTaskRecord[]>`
SELECT *
FROM long_tasks
WHERE created_by = ${userId}
ORDER BY created_at DESC
LIMIT ${limit}
`;
}
export async function claimNextTask() {
const staleSeconds = env.TASK_STALE_SECONDS;
return await db.begin(async (tx) => {
await tx`
UPDATE long_tasks
SET status = 'queued',
heartbeat_at = NULL,
started_at = NULL,
updated_at = NOW(),
error = COALESCE(error, 'Task lease expired and was re-queued')
WHERE status = 'running'
AND heartbeat_at IS NOT NULL
AND heartbeat_at < NOW() - (${staleSeconds}::text || ' seconds')::interval
AND attempts < max_attempts
`;
await tx`
UPDATE long_tasks
SET status = 'failed',
finished_at = NOW(),
updated_at = NOW(),
error = COALESCE(error, 'Task lease expired and max attempts reached')
WHERE status = 'running'
AND heartbeat_at IS NOT NULL
AND heartbeat_at < NOW() - (${staleSeconds}::text || ' seconds')::interval
AND attempts >= max_attempts
`;
const rows = await tx<LongTaskRecord[]>`
WITH candidate AS (
SELECT id
FROM long_tasks
WHERE status = 'queued'
AND scheduled_at <= NOW()
ORDER BY priority DESC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE long_tasks t
SET status = 'running',
started_at = COALESCE(t.started_at, NOW()),
heartbeat_at = NOW(),
attempts = t.attempts + 1,
updated_at = NOW()
FROM candidate
WHERE t.id = candidate.id
RETURNING t.*
`;
return rows[0] ?? null;
});
}
export async function heartbeatTask(taskId: string) {
await db`
UPDATE long_tasks
SET heartbeat_at = NOW(),
updated_at = NOW()
WHERE id = ${taskId}
AND status = 'running'
`;
}
export async function completeTask(taskId: string, result: Record<string, unknown>) {
await db`
UPDATE long_tasks
SET status = 'completed',
result = ${result},
error = NULL,
finished_at = NOW(),
heartbeat_at = NOW(),
updated_at = NOW()
WHERE id = ${taskId}
`;
}
export async function failTask(task: LongTaskRecord, reason: string, retryDelaySeconds = 20) {
const canRetry = task.attempts < task.max_attempts;
if (canRetry) {
await db`
UPDATE long_tasks
SET status = 'queued',
error = ${reason},
scheduled_at = NOW() + (${retryDelaySeconds}::text || ' seconds')::interval,
updated_at = NOW()
WHERE id = ${task.id}
`;
return;
}
await db`
UPDATE long_tasks
SET status = 'failed',
error = ${reason},
finished_at = NOW(),
updated_at = NOW()
WHERE id = ${task.id}
`;
}

View File

@@ -0,0 +1,52 @@
import { env } from '../config';
import { claimNextTask, completeTask, failTask, heartbeatTask } from './repository';
import { processTask } from './processors';
let keepRunning = true;
export function stopWorkerLoop() {
keepRunning = false;
}
function normalizeError(error: unknown) {
if (error instanceof Error) {
return `${error.name}: ${error.message}`;
}
return String(error);
}
export async function runWorkerLoop() {
console.log('[worker] started');
while (keepRunning) {
const task = await claimNextTask();
if (!task) {
await Bun.sleep(700);
continue;
}
console.log(`[worker] claimed task ${task.id} (${task.task_type})`);
const heartbeatTimer = setInterval(() => {
void heartbeatTask(task.id).catch((error) => {
console.error(`[worker] heartbeat failed for ${task.id}`, error);
});
}, env.TASK_HEARTBEAT_SECONDS * 1000);
try {
const result = await processTask(task);
await completeTask(task.id, result);
console.log(`[worker] completed task ${task.id}`);
} catch (error) {
const normalized = normalizeError(error);
console.error(`[worker] failed task ${task.id}`, normalized);
await failTask(task, normalized);
} finally {
clearInterval(heartbeatTimer);
}
}
console.log('[worker] stopping');
}

78
backend/src/types.ts Normal file
View File

@@ -0,0 +1,78 @@
export type FilingType = '10-K' | '10-Q' | '8-K';
export type FilingMetrics = {
revenue: number | null;
netIncome: number | null;
totalAssets: number | null;
cash: number | null;
debt: number | null;
};
export type FilingRecord = {
id: number;
ticker: string;
filing_type: FilingType;
filing_date: string;
accession_number: string;
cik: string;
company_name: string;
filing_url: string | null;
metrics: FilingMetrics | null;
analysis: Record<string, unknown> | null;
created_at: string;
updated_at: string;
};
export type HoldingRecord = {
id: number;
user_id: number;
ticker: string;
shares: string;
avg_cost: string;
current_price: string | null;
market_value: string;
gain_loss: string;
gain_loss_pct: string;
last_price_at: string | null;
created_at: string;
updated_at: string;
};
export type WatchlistRecord = {
id: number;
user_id: number;
ticker: string;
company_name: string;
sector: string | null;
created_at: string;
};
export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights';
export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed';
export type LongTaskRecord = {
id: string;
task_type: TaskType;
status: TaskStatus;
priority: number;
payload: Record<string, unknown>;
result: Record<string, unknown> | null;
error: string | null;
attempts: number;
max_attempts: number;
scheduled_at: string;
started_at: string | null;
heartbeat_at: string | null;
finished_at: string | null;
created_by: number | null;
created_at: string;
updated_at: string;
};
export type SessionUser = {
id: number;
email: string;
name: string | null;
image: string | null;
};

19
backend/src/worker.ts Normal file
View File

@@ -0,0 +1,19 @@
import { runWorkerLoop, stopWorkerLoop } from './tasks/worker-loop';
import { closeDb } from './db';
const shutdown = async (signal: string) => {
console.log(`[worker] received ${signal}`);
stopWorkerLoop();
await Bun.sleep(250);
await closeDb();
process.exit(0);
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
runWorkerLoop().catch(async (error) => {
console.error('[worker] fatal error', error);
await closeDb();
process.exit(1);
});

View File

@@ -9,9 +9,9 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
expose:
- "5432"
- '5432'
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-fiscal}"]
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-fiscal}']
interval: 5s
timeout: 5s
retries: 10
@@ -21,6 +21,7 @@ services:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
command: ['sh', '-c', 'bun run src/db/migrate.ts && bun run src/index.ts']
env_file:
- path: ./.env
required: false
@@ -30,33 +31,69 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal}
PORT: ${PORT:-3001}
POSTGRES_HOST: postgres
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-better-auth-secret-change-me}
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3001}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@example.com>}
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
TASK_HEARTBEAT_SECONDS: ${TASK_HEARTBEAT_SECONDS:-15}
TASK_STALE_SECONDS: ${TASK_STALE_SECONDS:-120}
TASK_MAX_ATTEMPTS: ${TASK_MAX_ATTEMPTS:-3}
expose:
- "3001"
- '3001'
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3001/api/health || exit 1"]
test: ['CMD-SHELL', 'wget -q --spider http://localhost:3001/api/health || exit 1']
interval: 30s
timeout: 10s
retries: 3
worker:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
command: ['sh', '-c', 'bun run src/db/migrate.ts && bun run src/worker.ts']
env_file:
- path: ./.env
required: false
- path: ../.env
required: false
environment:
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal}
PORT: ${PORT:-3001}
POSTGRES_HOST: postgres
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-better-auth-secret-change-me}
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3001}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@example.com>}
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
TASK_HEARTBEAT_SECONDS: ${TASK_HEARTBEAT_SECONDS:-15}
TASK_STALE_SECONDS: ${TASK_STALE_SECONDS:-120}
TASK_MAX_ATTEMPTS: ${TASK_MAX_ATTEMPTS:-3}
depends_on:
postgres:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://backend:3001}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
restart: unless-stopped
environment:
PORT: 3000
HOSTNAME: 0.0.0.0
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://backend:3001}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
expose:
- "3000"
- '3000'
depends_on:
- backend

78
docs/REBUILD_DECISIONS.md Normal file
View File

@@ -0,0 +1,78 @@
# Fiscal Clone Rebuild Decisions
This document records the ground-up design choices for the 2026 rebuild so every major decision is explicit and reviewable.
## 1) Architecture: split frontend and API
- Decision: keep `Next.js` in `frontend/` and a dedicated high-throughput API in `backend/`.
- Why: clean separation for scaling and deployment; web rendering and data ingestion do not contend for resources.
- Tradeoff: more services to run locally.
## 2) Runtime choice: Bun + Elysia for API
- Decision: use Bun runtime with Elysia for low overhead and fast cold/warm request handling.
- Why: strong performance profile for IO-heavy workloads (quotes, SEC fetch, queue polling).
- Tradeoff: narrower ecosystem compatibility than plain Node in some libraries.
## 3) Auth standard: Better Auth only
- Decision: use Better Auth end-to-end and remove legacy JWT/NextAuth patterns.
- Why: single auth surface across API and Next.js clients, DB-backed sessions, less custom auth code.
- Tradeoff: schema must align closely with Better Auth expectations.
## 4) Persistence: PostgreSQL as source of truth
- Decision: keep Postgres for all domain entities and task durability.
- Why: transactional consistency, mature operational tooling, simple backup/restore.
- Tradeoff: queue throughput is lower than specialized brokers at massive scale.
## 5) Long-running jobs: durable DB queue
- Decision: implement a durable `long_tasks` queue table plus dedicated worker process.
- Why: supports multi-minute jobs, retries, result persistence, and survives API restarts.
- Tradeoff: custom queue logic is more code than dropping in a broker library.
## 6) Async-first API for heavy workflows
- Decision: filing sync, filing analysis, and portfolio insights are queued and polled via `/api/tasks/:id`.
- Why: avoids request timeouts and keeps the UX responsive.
- Tradeoff: frontend must handle job lifecycle states.
## 7) AI integration contract for OpenClaw/ZeroClaw
- Decision: use an adapter that targets an OpenAI-compatible chat endpoint (`OPENCLAW_BASE_URL`) with model override (`OPENCLAW_MODEL`).
- Why: works with OpenClaw/ZeroClaw deployments while keeping provider lock-in low.
- Tradeoff: advanced provider-specific features are not exposed in v1.
## 8) SEC ingestion strategy
- Decision: fetch filings from SEC submissions API and enrich with company facts metrics.
- Why: stable machine-readable endpoints with less brittle parsing than HTML scraping.
- Tradeoff: facts can lag specific filing publication timing.
## 9) Market pricing strategy
- Decision: use Yahoo Finance chart endpoint for quote snapshots and periodic refresh.
- Why: good coverage and straightforward integration for portfolio mark-to-market.
- Tradeoff: endpoint reliability/quotas can vary; provider abstraction retained for future switch.
## 10) API shape: domain modules + strict schemas
- Decision: organize routes by domain (`portfolio`, `watchlist`, `filings`, `ai`, `tasks`) with Zod-style schema validation via Elysia types.
- Why: predictable contract boundaries and safer payload handling.
- Tradeoff: slight boilerplate cost.
## 11) Security posture
- Decision: all business endpoints require authenticated session resolution through Better Auth session API.
- Why: prevents cross-user data access and removes implicit trust in client-supplied user IDs.
- Tradeoff: each protected route performs auth/session checks.
## 12) Frontend rendering model
- Decision: use Next.js App Router with client-heavy dashboards where live polling is required.
- Why: server rendering for shell + interactive client zones for real-time task/market updates.
- Tradeoff: more client-side state management in dashboard screens.
## 13) Design language: terminal-futurist UI system
- Decision: build a clear terminal-inspired design with grid scanlines, mono + geometric type pairing, and neon cyan/green accent palette.
- Why: matches requested futuristic terminal aesthetic while remaining readable.
- Tradeoff: highly stylized branding may not fit conservative enterprise environments.
## 14) Performance defaults
- Decision: optimize for fewer round trips (batched fetches), async processing, indexed SQL, and paginated list endpoints.
- Why: improves p95 latency under concurrent load.
- Tradeoff: slightly more complex query/service code.
## 15) Operations model
- Decision: run three processes in production: frontend, backend API, backend worker.
- Why: isolates web traffic from heavy background processing and enables independent scaling.
- Tradeoff: additional deployment/health-check wiring.

View File

@@ -1,17 +1,24 @@
'use client';
import { signIn, useSession } from '@/lib/better-auth';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export default function SignIn() {
const { data: session, isPending: sessionPending } = useSession();
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn, useSession } from '@/lib/better-auth';
import { AuthShell } from '@/components/auth/auth-shell';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export default function SignInPage() {
const router = useRouter();
const { data: session, isPending: sessionPending } = useSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sessionPending && session?.user) {
@@ -19,91 +26,64 @@ export default function SignIn() {
}
}, [sessionPending, session, router]);
const handleCredentialsLogin = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError('');
setError(null);
try {
const result = await signIn.email({
email,
password,
});
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message || 'Invalid credentials');
return;
} else {
router.replace('/');
router.refresh();
}
router.replace('/');
router.refresh();
} catch (err) {
setError('Login failed');
} catch {
setError('Sign in failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-slate-800/50 rounded-lg p-8 border border-slate-700">
<h1 className="text-3xl font-bold text-center mb-2 bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Fiscal Clone
</h1>
<p className="text-slate-400 text-center mb-8">Sign in to your account</p>
<AuthShell
title="Sign in"
subtitle="Authenticate with Better Auth session-backed credentials."
footer={(
<>
No account yet?{' '}
<Link href="/auth/signup" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Create one
</Link>
</>
)}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm text-[color:var(--terminal-muted)]">Email</label>
<Input type="email" required value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@company.com" />
</div>
{error && (
<div className="bg-red-500/20 border border-red-500 text-red-400 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
<div>
<label className="mb-1 block text-sm text-[color:var(--terminal-muted)]">Password</label>
<Input
type="password"
required
minLength={8}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="********"
/>
</div>
<form onSubmit={handleCredentialsLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
required
/>
</div>
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="•••••••••"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading || sessionPending}
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white font-semibold py-3 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p className="mt-8 text-center text-sm text-slate-400">
Don&apos;t have an account?{' '}
<a href="/auth/signup" className="text-blue-400 hover:text-blue-300">
Sign up
</a>
</p>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading || sessionPending}>
{loading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
</AuthShell>
);
}

View File

@@ -1,18 +1,25 @@
'use client';
import { signUp, useSession } from '@/lib/better-auth';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export default function SignUp() {
const { data: session, isPending: sessionPending } = useSession();
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { signUp, useSession } from '@/lib/better-auth';
import { AuthShell } from '@/components/auth/auth-shell';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export default function SignUpPage() {
const router = useRouter();
const { data: session, isPending: sessionPending } = useSession();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sessionPending && session?.user) {
@@ -20,25 +27,25 @@ export default function SignUp() {
}
}, [sessionPending, session, router]);
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError('');
setError(null);
try {
const result = await signUp.email({
email,
password,
name,
email,
password
});
if (result.error) {
setError(result.error.message || 'Sign up failed');
setError(result.error.message || 'Unable to create account');
} else {
router.replace('/');
router.refresh();
}
} catch (err) {
} catch {
setError('Sign up failed');
} finally {
setLoading(false);
@@ -46,79 +53,47 @@ export default function SignUp() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-slate-800/50 rounded-lg p-8 border border-slate-700">
<h1 className="text-3xl font-bold text-center mb-2 bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Fiscal Clone
</h1>
<p className="text-slate-400 text-center mb-8">Create your account</p>
{error && (
<div className="bg-red-500/20 border border-red-500 text-red-400 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
<form onSubmit={handleSignUp} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your name"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="you@example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-slate-700/50 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="•••••••••"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading || sessionPending}
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white font-semibold py-3 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
<p className="mt-8 text-center text-sm text-slate-400">
Already have an account?{' '}
<a href="/auth/signin" className="text-blue-400 hover:text-blue-300">
<AuthShell
title="Create account"
subtitle="Provision an analyst workspace with Better Auth sessions."
footer={(
<>
Already registered?{' '}
<Link href="/auth/signin" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Sign in
</a>
</p>
</div>
</div>
</Link>
</>
)}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm text-[color:var(--terminal-muted)]">Name</label>
<Input required value={name} onChange={(event) => setName(event.target.value)} placeholder="Operator name" />
</div>
<div>
<label className="mb-1 block text-sm text-[color:var(--terminal-muted)]">Email</label>
<Input type="email" required value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@company.com" />
</div>
<div>
<label className="mb-1 block text-sm text-[color:var(--terminal-muted)]">Password</label>
<Input
type="password"
required
minLength={8}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Minimum 8 characters"
/>
</div>
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
<Button type="submit" className="w-full" disabled={loading || sessionPending}>
{loading ? 'Creating account...' : 'Create account'}
</Button>
</form>
</AuthShell>
);
}

View File

@@ -1,185 +1,251 @@
'use client';
import { useSession } from '@/lib/better-auth';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { format } from 'date-fns';
import { Bot, Download, Search, TimerReset } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { getTask, listFilings, queueFilingAnalysis, queueFilingSync } from '@/lib/api';
import type { Filing, Task } from '@/lib/types';
import { formatCompactCurrency } from '@/lib/format';
export default function FilingsPage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [filings, setFilings] = useState([]);
const { isPending, isAuthenticated } = useAuthGuard();
const searchParams = useSearchParams();
const [filings, setFilings] = useState<Filing[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [syncTickerInput, setSyncTickerInput] = useState('');
const [filterTickerInput, setFilterTickerInput] = useState('');
const [searchTicker, setSearchTicker] = useState('');
const [activeTask, setActiveTask] = useState<Task | null>(null);
useEffect(() => {
if (!isPending && !session) {
router.push('/auth/signin');
return;
const ticker = searchParams.get('ticker');
if (ticker) {
const normalized = ticker.toUpperCase();
setSyncTickerInput(normalized);
setFilterTickerInput(normalized);
setSearchTicker(normalized);
}
}, [searchParams]);
if (session?.user) {
fetchFilings();
}
}, [session, isPending, router]);
const fetchFilings = async (ticker?: string) => {
const loadFilings = useCallback(async (ticker?: string) => {
setLoading(true);
try {
const url = ticker
? `${process.env.NEXT_PUBLIC_API_URL}/api/filings/${ticker}`
: `${process.env.NEXT_PUBLIC_API_URL}/api/filings`;
setError(null);
const response = await fetch(url);
const data = await response.json();
setFilings(data);
} catch (error) {
console.error('Error fetching filings:', error);
try {
const response = await listFilings({ ticker, limit: 120 });
setFilings(response.filings);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to fetch filings');
} finally {
setLoading(false);
}
};
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
fetchFilings(searchTicker || undefined);
};
useEffect(() => {
if (!isPending && isAuthenticated) {
void loadFilings(searchTicker || undefined);
}
}, [isPending, isAuthenticated, searchTicker, loadFilings]);
const polledTask = useTaskPoller({
taskId: activeTask?.id ?? null,
onTerminalState: async () => {
setActiveTask(null);
await loadFilings(searchTicker || undefined);
}
});
const liveTask = polledTask ?? activeTask;
const triggerSync = async () => {
if (!syncTickerInput.trim()) {
return;
}
const handleRefresh = async (ticker: string) => {
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/filings/refresh/${ticker}`, {
method: 'POST'
});
fetchFilings(ticker);
} catch (error) {
console.error('Error refreshing filings:', error);
const { task } = await queueFilingSync({ ticker: syncTickerInput.trim().toUpperCase(), limit: 20 });
const latest = await getTask(task.id);
setActiveTask(latest.task);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue filing sync');
}
};
const getFilingTypeColor = (type: string) => {
switch (type) {
case '10-K': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case '10-Q': return 'bg-green-500/20 text-green-400 border-green-500/30';
case '8-K': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
const triggerAnalysis = async (accessionNumber: string) => {
try {
const { task } = await queueFilingAnalysis(accessionNumber);
const latest = await getTask(task.id);
setActiveTask(latest.task);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue filing analysis');
}
};
if (loading) {
return <div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center">Loading...</div>;
const groupedByTicker = useMemo(() => {
const counts = new Map<string, number>();
for (const filing of filings) {
counts.set(filing.ticker, (counts.get(filing.ticker) ?? 0) + 1);
}
return counts;
}, [filings]);
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Opening filings stream...</div>;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
<nav className="border-b border-slate-700 bg-slate-900/50 backdrop-blur">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Fiscal Clone
</Link>
<div className="flex gap-4">
<Link href="/filings" className="hover:text-blue-400 transition">
Filings
</Link>
<Link href="/portfolio" className="hover:text-blue-400 transition">
Portfolio
</Link>
<Link href="/watchlist" className="hover:text-blue-400 transition">
Watchlist
</Link>
<AppShell
title="Filings Stream"
subtitle="Sync SEC submissions and generate AI red-flag analysis asynchronously."
actions={(
<Button variant="secondary" onClick={() => void loadFilings(searchTicker || undefined)}>
<TimerReset className="size-4" />
Refresh table
</Button>
)}
>
{liveTask ? (
<Panel title="Active Task" subtitle={`${liveTask.task_type} is processing in worker.`}>
<div className="flex items-center justify-between gap-3 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{liveTask.id}</p>
<StatusPill status={liveTask.status} />
</div>
</div>
</nav>
{liveTask.error ? <p className="mt-2 text-sm text-[#ff9898]">{liveTask.error}</p> : null}
</Panel>
) : null}
<main className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">SEC Filings</h1>
<Link
href="/watchlist/add"
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1.2fr_1fr]">
<Panel title="Sync Controller" subtitle="Queue ingestion jobs by ticker symbol.">
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(event) => {
event.preventDefault();
void triggerSync();
}}
>
+ Add to Watchlist
</Link>
</div>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700 mb-8">
<form onSubmit={handleSearch} className="flex gap-4">
<input
type="text"
value={searchTicker}
onChange={(e) => setSearchTicker(e.target.value)}
className="flex-1 bg-slate-700/50 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search by ticker (e.g., AAPL)"
<Input
value={syncTickerInput}
onChange={(event) => setSyncTickerInput(event.target.value.toUpperCase())}
placeholder="Ticker (AAPL)"
className="max-w-xs"
/>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg transition"
>
Search
</button>
<button
<Button type="submit">
<Download className="size-4" />
Queue sync
</Button>
</form>
</Panel>
<Panel title="Search Index" subtitle="Filter by ticker in the local filing index.">
<form
className="flex flex-wrap items-center gap-3"
onSubmit={(event) => {
event.preventDefault();
setSearchTicker(filterTickerInput.trim().toUpperCase());
}}
>
<Input
value={filterTickerInput}
onChange={(event) => setFilterTickerInput(event.target.value.toUpperCase())}
placeholder="Ticker filter"
className="max-w-xs"
/>
<Button type="submit" variant="secondary">
<Search className="size-4" />
Apply
</Button>
<Button
type="button"
onClick={() => { setSearchTicker(''); fetchFilings(); }}
className="bg-slate-700 hover:bg-slate-600 px-6 py-3 rounded-lg transition"
variant="ghost"
onClick={() => {
setFilterTickerInput('');
setSearchTicker('');
}}
>
Clear
</button>
</Button>
</form>
</div>
</Panel>
</div>
<div className="bg-slate-800/50 rounded-lg border border-slate-700 overflow-hidden">
{filings.length > 0 ? (
<table className="w-full">
<thead className="bg-slate-700/50">
<Panel title="Filing Ledger" subtitle={`${filings.length} records loaded${searchTicker ? ` for ${searchTicker}` : ''}.`}>
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Fetching filings...</p>
) : filings.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No filings available. Queue a sync job to ingest fresh SEC data.</p>
) : (
<div className="overflow-x-auto">
<table className="data-table min-w-[980px]">
<thead>
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Ticker</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Company</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Type</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Filing Date</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Actions</th>
<th>Ticker</th>
<th>Type</th>
<th>Filed</th>
<th>Revenue Snapshot</th>
<th>Company</th>
<th>AI</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{filings.map((filing: any) => (
<tr key={filing.id} className="border-t border-slate-700 hover:bg-slate-700/30 transition">
<td className="px-6 py-4 font-semibold">{filing.ticker}</td>
<td className="px-6 py-4">{filing.company_name}</td>
<td className="px-6 py-4">
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getFilingTypeColor(filing.filing_type)}`}>
{filing.filing_type}
</span>
</td>
<td className="px-6 py-4">
{format(new Date(filing.filing_date), 'MMM dd, yyyy')}
</td>
<td className="px-6 py-4">
<button
onClick={() => window.open(`https://www.sec.gov/Archives/${filing.accession_number.replace(/-/g, '')}/${filing.accession_number}-index.htm`, '_blank')}
className="text-blue-400 hover:text-blue-300 transition mr-4"
>
View on SEC
</button>
<button
onClick={() => handleRefresh(filing.ticker)}
className="text-green-400 hover:text-green-300 transition"
>
Refresh
</button>
</td>
</tr>
))}
{filings.map((filing) => {
const revenue = filing.metrics?.revenue;
const hasAnalysis = Boolean(filing.analysis?.text || filing.analysis?.legacyInsights);
return (
<tr key={filing.accession_number}>
<td>
<div className="font-medium text-[color:var(--terminal-bright)]">{filing.ticker}</div>
<div className="text-xs text-[color:var(--terminal-muted)]">{groupedByTicker.get(filing.ticker)} filings</div>
</td>
<td>{filing.filing_type}</td>
<td>{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</td>
<td>{revenue ? formatCompactCurrency(revenue) : 'n/a'}</td>
<td>{filing.company_name}</td>
<td>{hasAnalysis ? 'Ready' : 'Not generated'}</td>
<td>
<div className="flex items-center gap-2">
{filing.filing_url ? (
<a
href={filing.filing_url}
target="_blank"
rel="noreferrer"
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
SEC
</a>
) : null}
<Button
variant="ghost"
onClick={() => void triggerAnalysis(filing.accession_number)}
className="px-2 py-1 text-xs"
>
<Bot className="size-3" />
Analyze
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="text-center py-12">
<p className="text-slate-400 text-lg mb-4">No filings found</p>
<p className="text-slate-500 text-sm">
Add stocks to your watchlist to track their SEC filings
</p>
</div>
)}
</div>
</main>
</div>
</div>
)}
</Panel>
</AppShell>
);
}

View File

@@ -2,20 +2,124 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
:root {
--bg-0: #05080d;
--bg-1: #08121a;
--bg-2: #0b1f28;
--panel: rgba(6, 17, 24, 0.8);
--panel-soft: rgba(7, 22, 31, 0.62);
--panel-bright: rgba(10, 33, 45, 0.9);
--line-weak: rgba(126, 217, 255, 0.22);
--line-strong: rgba(123, 255, 217, 0.75);
--accent: #68ffd5;
--accent-strong: #8cffeb;
--danger: #ff7070;
--danger-soft: rgba(122, 33, 33, 0.44);
--terminal-bright: #e8fff8;
--terminal-muted: #94b9c5;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
font-family: var(--font-display), sans-serif;
color: var(--terminal-bright);
background:
radial-gradient(circle at 18% -10%, rgba(126, 217, 255, 0.25), transparent 35%),
radial-gradient(circle at 84% 0%, rgba(104, 255, 213, 0.2), transparent 30%),
linear-gradient(140deg, var(--bg-0), var(--bg-1) 50%, var(--bg-2));
}
.app-surface,
.auth-page {
position: relative;
min-height: 100vh;
overflow: hidden;
}
.ambient-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(126, 217, 255, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(126, 217, 255, 0.07) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: radial-gradient(ellipse at center, black 20%, transparent 75%);
pointer-events: none;
}
.noise-layer {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.3;
background-image: radial-gradient(rgba(160, 255, 227, 0.15) 0.7px, transparent 0.7px);
background-size: 4px 4px;
}
.terminal-caption {
font-family: var(--font-mono), monospace;
}
.panel-heading {
font-family: var(--font-mono), monospace;
letter-spacing: 0.08em;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
border-bottom: 1px solid var(--line-weak);
padding: 0.75rem 0.65rem;
text-align: left;
font-size: 0.875rem;
}
.data-table th {
font-family: var(--font-mono), monospace;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--terminal-muted);
}
.data-table tbody tr:hover {
background-color: rgba(17, 47, 61, 0.45);
}
@media (prefers-reduced-motion: no-preference) {
.ambient-grid {
animation: subtle-grid-shift 18s linear infinite;
}
@keyframes subtle-grid-shift {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(-8px);
}
100% {
transform: translateY(0px);
}
}
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
@media (max-width: 1024px) {
.ambient-grid {
background-size: 26px 26px;
}
}

View File

@@ -1,15 +1,26 @@
import './globals.css';
import type { Metadata } from 'next';
import { JetBrains_Mono, Space_Grotesk } from 'next/font/google';
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const display = Space_Grotesk({
subsets: ['latin'],
variable: '--font-display'
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono'
});
export const metadata: Metadata = {
title: 'Fiscal Clone',
description: 'Futuristic fiscal intelligence terminal powered by Better Auth and durable AI tasks.'
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
</body>
<html lang="en" className={`${display.variable} ${mono.variable}`}>
<body>{children}</body>
</html>
)
);
}

View File

@@ -1,110 +1,229 @@
'use client';
import { useSession } from '@/lib/better-auth';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Activity, Bot, RefreshCw, Sparkles } from 'lucide-react';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { MetricCard } from '@/components/dashboard/metric-card';
import { TaskFeed } from '@/components/dashboard/task-feed';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import {
getLatestPortfolioInsight,
getPortfolioSummary,
getTask,
listFilings,
listRecentTasks,
listWatchlist,
queuePortfolioInsights,
queuePriceRefresh
} from '@/lib/api';
import type { PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format';
export default function Home() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [stats, setStats] = useState({ filings: 0, portfolioValue: 0, watchlist: 0 });
type DashboardState = {
summary: PortfolioSummary;
filingsCount: number;
watchlistCount: number;
tasks: Task[];
latestInsight: PortfolioInsight | null;
};
useEffect(() => {
if (!isPending && !session) {
router.push('/auth/signin');
return;
}
const EMPTY_STATE: DashboardState = {
summary: {
positions: 0,
total_value: '0',
total_gain_loss: '0',
total_cost_basis: '0',
avg_return_pct: '0'
},
filingsCount: 0,
watchlistCount: 0,
tasks: [],
latestInsight: null
};
if (session?.user) {
fetchStats(session.user.id);
}
}, [session, isPending, router]);
export default function CommandCenterPage() {
const { isPending, isAuthenticated, session } = useAuthGuard();
const [state, setState] = useState<DashboardState>(EMPTY_STATE);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
const fetchStats = async (userId: string) => {
try {
const [portfolioRes, watchlistRes, filingsRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/portfolio/${userId}/summary`),
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/watchlist/${userId}`),
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/filings`)
const [summaryRes, filingsRes, watchlistRes, tasksRes, insightRes] = await Promise.all([
getPortfolioSummary(),
listFilings({ limit: 200 }),
listWatchlist(),
listRecentTasks(20),
getLatestPortfolioInsight()
]);
const portfolioData = await portfolioRes.json();
const watchlistData = await watchlistRes.json();
const filingsData = await filingsRes.json();
setStats({
filings: filingsData.length || 0,
portfolioValue: portfolioData.total_value || 0,
watchlist: watchlistData.length || 0
setState({
summary: summaryRes.summary,
filingsCount: filingsRes.filings.length,
watchlistCount: watchlistRes.items.length,
tasks: tasksRes.tasks,
latestInsight: insightRes.insight
});
} catch (error) {
console.error('Error fetching stats:', error);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (!isPending && isAuthenticated) {
void loadData();
}
}, [isPending, isAuthenticated, loadData]);
const trackedTask = useTaskPoller({
taskId: activeTaskId,
onTerminalState: () => {
setActiveTaskId(null);
void loadData();
}
});
const headerActions = (
<>
<Button
variant="secondary"
onClick={async () => {
try {
const { task } = await queuePriceRefresh();
setActiveTaskId(task.id);
const latest = await getTask(task.id);
setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue price refresh');
}
}}
>
<RefreshCw className="size-4" />
Refresh prices
</Button>
<Button
onClick={async () => {
try {
const { task } = await queuePortfolioInsights();
setActiveTaskId(task.id);
const latest = await getTask(task.id);
setState((prev) => ({ ...prev, tasks: [latest.task, ...prev.tasks] }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue AI insight');
}
}}
>
<Sparkles className="size-4" />
Queue AI insight
</Button>
</>
);
const signedGain = useMemo(() => {
const gain = Number(state.summary.total_gain_loss ?? 0);
return gain >= 0 ? `+${formatCurrency(gain)}` : formatCurrency(gain);
}, [state.summary.total_gain_loss]);
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Booting secure terminal...</div>;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
<nav className="border-b border-slate-700 bg-slate-900/50 backdrop-blur">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Fiscal Clone
<AppShell
title="Command Center"
subtitle={`Welcome back${session?.user?.name ? `, ${session.user.name}` : ''}. Review tasks, portfolio health, and AI outputs.`}
actions={headerActions}
>
{activeTaskId && trackedTask ? (
<Panel title="Live Task" subtitle={`Task ${activeTaskId.slice(0, 8)} is active.`}>
<div className="flex items-center justify-between gap-3 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{trackedTask.task_type.replace('_', ' ')}</p>
<StatusPill status={trackedTask.status} />
</div>
{trackedTask.error ? (
<p className="mt-3 text-sm text-[#ff9898]">{trackedTask.error}</p>
) : null}
</Panel>
) : null}
{error ? (
<Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p>
</Panel>
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Portfolio Value" value={formatCurrency(state.summary.total_value)} delta={formatCompactCurrency(state.summary.total_cost_basis)} />
<MetricCard
label="Unrealized P&L"
value={signedGain}
delta={formatPercent(state.summary.avg_return_pct)}
positive={Number(state.summary.total_gain_loss) >= 0}
/>
<MetricCard label="Tracked Filings" value={String(state.filingsCount)} delta="Last 200 records" />
<MetricCard label="Watchlist Nodes" value={String(state.watchlistCount)} delta={`${state.summary.positions} positions active`} />
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<Panel title="Recent Tasks" subtitle="Durable jobs from queue processor" className="xl:col-span-1">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading tasks...</p>
) : (
<TaskFeed tasks={state.tasks} />
)}
</Panel>
<Panel title="AI Brief" subtitle="Latest portfolio insight from OpenClaw/ZeroClaw" className="xl:col-span-2">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading intelligence output...</p>
) : state.latestInsight ? (
<>
<div className="mb-3 inline-flex items-center gap-2 rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-muted)]">
<Bot className="size-3.5" />
{state.latestInsight.provider} :: {state.latestInsight.model}
</div>
<p className="whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{state.latestInsight.content}</p>
</>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No AI brief yet. Queue one from the action bar.</p>
)}
</Panel>
</div>
<Panel title="Quick Links" subtitle="Feature modules">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Link className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]" href="/filings">
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Filings</p>
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Sync SEC filings and trigger AI memo analysis.</p>
</Link>
<Link className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]" href="/portfolio">
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Portfolio</p>
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Manage holdings and mark to market in real time.</p>
</Link>
<Link className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]" href="/watchlist">
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Watchlist</p>
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Track priority tickers for monitoring and ingestion.</p>
</Link>
<div className="flex gap-4">
<Link href="/filings" className="hover:text-blue-400 transition">
Filings
</Link>
<Link href="/portfolio" className="hover:text-blue-400 transition">
Portfolio
</Link>
<Link href="/watchlist" className="hover:text-blue-400 transition">
Watchlist
</Link>
</div>
</div>
</nav>
</Panel>
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-slate-400">Welcome back, {session?.user?.name}</p>
<Panel>
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
<Activity className="size-4" />
Runtime state: {loading ? 'syncing' : 'stable'}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-slate-400 mb-2">Total Filings</h3>
<p className="text-4xl font-bold text-blue-400">{stats.filings}</p>
</div>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-slate-400 mb-2">Portfolio Value</h3>
<p className="text-4xl font-bold text-green-400">
${stats.portfolioValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-slate-400 mb-2">Watchlist</h3>
<p className="text-4xl font-bold text-purple-400">{stats.watchlist}</p>
</div>
</div>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h2 className="text-xl font-semibold mb-4">Quick Actions</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link href="/watchlist/add" className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg text-center transition">
Add to Watchlist
</Link>
<Link href="/portfolio" className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg text-center transition">
Add to Portfolio
</Link>
<Link href="/filings" className="bg-slate-700 hover:bg-slate-600 text-white px-6 py-3 rounded-lg text-center transition">
Search SEC Filings
</Link>
<Link href="/portfolio" className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg text-center transition">
View Portfolio
</Link>
</div>
</div>
</main>
</div>
</Panel>
</AppShell>
);
}

View File

@@ -1,301 +1,332 @@
'use client';
import { useSession } from '@/lib/better-auth';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
import { format } from 'date-fns';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
import { BrainCircuit, Plus, RefreshCcw, Trash2 } from 'lucide-react';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import {
deleteHolding,
getLatestPortfolioInsight,
getTask,
getPortfolioSummary,
listHoldings,
queuePortfolioInsights,
queuePriceRefresh,
upsertHolding
} from '@/lib/api';
import type { Holding, PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
import { asNumber, formatCurrency, formatPercent } from '@/lib/format';
type FormState = {
ticker: string;
shares: string;
avgCost: string;
currentPrice: string;
};
const CHART_COLORS = ['#6effd8', '#5fd3ff', '#66ffa1', '#8dbbff', '#f4f88f', '#ff9c9c'];
const EMPTY_SUMMARY: PortfolioSummary = {
positions: 0,
total_value: '0',
total_gain_loss: '0',
total_cost_basis: '0',
avg_return_pct: '0'
};
export default function PortfolioPage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [portfolio, setPortfolio] = useState([]);
const [summary, setSummary] = useState({ total_value: 0, total_gain_loss: 0, cost_basis: 0 });
const { isPending, isAuthenticated } = useAuthGuard();
const [holdings, setHoldings] = useState<Holding[]>([]);
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newHolding, setNewHolding] = useState({ ticker: '', shares: '', avg_cost: '' });
const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
useEffect(() => {
if (!isPending && !session) {
router.push('/auth/signin');
return;
}
const loadPortfolio = useCallback(async () => {
setLoading(true);
setError(null);
if (session?.user?.id) {
fetchPortfolio(session.user.id);
}
}, [session, isPending, router]);
const fetchPortfolio = async (userId: string) => {
try {
const [portfolioRes, summaryRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/portfolio/${userId}`),
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/portfolio/${userId}/summary`)
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
listHoldings(),
getPortfolioSummary(),
getLatestPortfolioInsight()
]);
const portfolioData = await portfolioRes.json();
const summaryData = await summaryRes.json();
setPortfolio(portfolioData);
setSummary(summaryData);
} catch (error) {
console.error('Error fetching portfolio:', error);
setHoldings(holdingsRes.holdings);
setSummary(summaryRes.summary);
setLatestInsight(insightRes.insight);
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not fetch portfolio data');
} finally {
setLoading(false);
}
};
}, []);
const handleAddHolding = async (e: React.FormEvent) => {
e.preventDefault();
const userId = session?.user?.id;
if (!userId) return;
useEffect(() => {
if (!isPending && isAuthenticated) {
void loadPortfolio();
}
}, [isPending, isAuthenticated, loadPortfolio]);
const polledTask = useTaskPoller({
taskId: activeTask?.id ?? null,
onTerminalState: async () => {
setActiveTask(null);
await loadPortfolio();
}
});
const liveTask = polledTask ?? activeTask;
const allocationData = useMemo(
() => holdings.map((holding) => ({
name: holding.ticker,
value: asNumber(holding.market_value)
})),
[holdings]
);
const performanceData = useMemo(
() => holdings.map((holding) => ({
name: holding.ticker,
value: asNumber(holding.gain_loss_pct)
})),
[holdings]
);
const submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/portfolio`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
ticker: newHolding.ticker.toUpperCase(),
shares: parseFloat(newHolding.shares),
avg_cost: parseFloat(newHolding.avg_cost)
})
await upsertHolding({
ticker: form.ticker.toUpperCase(),
shares: Number(form.shares),
avgCost: Number(form.avgCost),
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
});
setShowAddModal(false);
setNewHolding({ ticker: '', shares: '', avg_cost: '' });
fetchPortfolio(userId);
} catch (error) {
console.error('Error adding holding:', error);
setForm({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save holding');
}
};
const handleDeleteHolding = async (id: number) => {
if (!confirm('Are you sure you want to delete this holding?')) return;
const userId = session?.user?.id;
if (!userId) return;
const queueRefresh = async () => {
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/portfolio/${id}`, {
method: 'DELETE'
});
fetchPortfolio(userId);
} catch (error) {
console.error('Error deleting holding:', error);
const { task } = await queuePriceRefresh();
const latest = await getTask(task.id);
setActiveTask(latest.task);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
}
};
if (loading) {
return <div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center">Loading...</div>;
const queueInsights = async () => {
try {
const { task } = await queuePortfolioInsights();
const latest = await getTask(task.id);
setActiveTask(latest.task);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights');
}
};
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading portfolio matrix...</div>;
}
const pieData = portfolio.length > 0 ? portfolio.map((p: any) => ({
name: p.ticker,
value: p.current_value || (p.shares * p.avg_cost)
})) : [];
const COLORS = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#ec4899'];
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
<nav className="border-b border-slate-700 bg-slate-900/50 backdrop-blur">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Fiscal Clone
</Link>
<div className="flex gap-4">
<Link href="/filings" className="hover:text-blue-400 transition">
Filings
</Link>
<Link href="/portfolio" className="hover:text-blue-400 transition">
Portfolio
</Link>
<Link href="/watchlist" className="hover:text-blue-400 transition">
Watchlist
</Link>
<AppShell
title="Portfolio Matrix"
subtitle="Position management, market valuation, and AI generated portfolio commentary."
actions={(
<>
<Button variant="secondary" onClick={() => void queueRefresh()}>
<RefreshCcw className="size-4" />
Queue price refresh
</Button>
<Button onClick={() => void queueInsights()}>
<BrainCircuit className="size-4" />
Generate AI brief
</Button>
</>
)}
>
{liveTask ? (
<Panel title="Task Runner" subtitle={liveTask.id}>
<div className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{liveTask.task_type}</p>
<StatusPill status={liveTask.status} />
</div>
</div>
</nav>
{liveTask.error ? <p className="mt-2 text-sm text-[#ff9f9f]">{liveTask.error}</p> : null}
</Panel>
) : null}
<main className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Portfolio</h1>
<button
onClick={() => setShowAddModal(true)}
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
>
+ Add Holding
</button>
</div>
{error ? (
<Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p>
</Panel>
) : null}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-slate-400 mb-2">Total Value</h3>
<p className="text-3xl font-bold text-green-400">
${summary.total_value?.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '$0.00'}
</p>
</div>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-slate-400 mb-2">Total Gain/Loss</h3>
<p className={`text-3xl font-bold ${summary.total_gain_loss >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{summary.total_gain_loss >= 0 ? '+' : ''}${summary.total_gain_loss?.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '$0.00'}
</p>
</div>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-slate-400 mb-2">Positions</h3>
<p className="text-3xl font-bold text-blue-400">{portfolio.length}</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Panel title="Total Value" className="lg:col-span-1">
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(summary.total_value)}</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Cost basis {formatCurrency(summary.total_cost_basis)}</p>
</Panel>
<Panel title="Unrealized P&L" className="lg:col-span-1">
<p className={`text-3xl font-semibold ${asNumber(summary.total_gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
{formatCurrency(summary.total_gain_loss)}
</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Average return {formatPercent(summary.avg_return_pct)}</p>
</Panel>
<Panel title="Positions" className="lg:col-span-1">
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{summary.positions}</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Active symbols in portfolio.</p>
</Panel>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-xl font-semibold mb-4">Portfolio Allocation</h3>
{pieData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<Panel title="Allocation">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
) : allocationData.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label={(entry) => `${entry.name} ($${(entry.value / 1000).toFixed(1)}k)`}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
{allocationData.map((entry, index) => (
<Cell key={`${entry.name}-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Tooltip formatter={(value: number) => formatCurrency(value)} />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-slate-400 text-center py-8">No holdings yet</p>
)}
</div>
</div>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings yet.</p>
)}
</Panel>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<h3 className="text-xl font-semibold mb-4">Performance</h3>
{portfolio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={portfolio.map((p: any) => ({ name: p.ticker, value: p.gain_loss_pct || 0 }))}>
<XAxis dataKey="name" stroke="#64748b" />
<YAxis stroke="#64748b" />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '#334155', borderRadius: '8px' }} />
<Line type="monotone" dataKey="value" stroke="#8b5cf6" strokeWidth={2} dot={{ fill: '#8b5cf6' }} />
</LineChart>
<Panel title="Performance %">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
) : performanceData.length > 0 ? (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={performanceData}>
<CartesianGrid strokeDasharray="2 2" stroke="rgba(126, 217, 255, 0.2)" />
<XAxis dataKey="name" stroke="#8cb6c5" fontSize={12} />
<YAxis stroke="#8cb6c5" fontSize={12} />
<Tooltip formatter={(value: number) => `${value.toFixed(2)}%`} />
<Bar dataKey="value" fill="#68ffd5" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-slate-400 text-center py-8">No performance data yet</p>
)}
</div>
</div>
</div>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No performance data yet.</p>
)}
</Panel>
</div>
<div className="bg-slate-800/50 rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-700/50">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Ticker</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Shares</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Avg Cost</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Current Price</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Value</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Gain/Loss</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">%</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-300">Actions</th>
</tr>
</thead>
<tbody>
{portfolio.map((holding: any) => (
<tr key={holding.id} className="border-t border-slate-700 hover:bg-slate-700/30 transition">
<td className="px-6 py-4 font-semibold">{holding.ticker}</td>
<td className="px-6 py-4">{holding.shares.toLocaleString()}</td>
<td className="px-6 py-4">${holding.avg_cost.toFixed(2)}</td>
<td className="px-6 py-4">${holding.current_price?.toFixed(2) || 'N/A'}</td>
<td className="px-6 py-4">${holding.current_value?.toFixed(2) || 'N/A'}</td>
<td className={`px-6 py-4 ${holding.gain_loss >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{holding.gain_loss >= 0 ? '+' : ''}${holding.gain_loss?.toFixed(2) || '0.00'}
</td>
<td className={`px-6 py-4 ${holding.gain_loss_pct >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{holding.gain_loss_pct >= 0 ? '+' : ''}{holding.gain_loss_pct?.toFixed(2) || '0.00'}%
</td>
<td className="px-6 py-4">
<button
onClick={() => handleDeleteHolding(holding.id)}
className="text-red-400 hover:text-red-300 transition"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.5fr_1fr]">
<Panel title="Holdings Table" subtitle="Live mark-to-market values from latest refresh.">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading holdings...</p>
) : holdings.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
) : (
<div className="overflow-x-auto">
<table className="data-table min-w-[780px]">
<thead>
<tr>
<th>Ticker</th>
<th>Shares</th>
<th>Avg Cost</th>
<th>Price</th>
<th>Value</th>
<th>Gain/Loss</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{holdings.map((holding) => (
<tr key={holding.id}>
<td>{holding.ticker}</td>
<td>{asNumber(holding.shares).toLocaleString()}</td>
<td>{formatCurrency(holding.avg_cost)}</td>
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
<td>{formatCurrency(holding.market_value)}</td>
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
</td>
<td>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteHolding(holding.id);
await loadPortfolio();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete holding');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4">
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4">Add Holding</h2>
<form onSubmit={handleAddHolding} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Ticker</label>
<input
type="text"
value={newHolding.ticker}
onChange={(e) => setNewHolding({...newHolding, ticker: e.target.value})}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-3 text-white"
placeholder="AAPL"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Shares</label>
<input
type="number"
step="0.0001"
value={newHolding.shares}
onChange={(e) => setNewHolding({...newHolding, shares: e.target.value})}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-3 text-white"
placeholder="100"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Average Cost</label>
<input
type="number"
step="0.01"
value={newHolding.avg_cost}
onChange={(e) => setNewHolding({...newHolding, avg_cost: e.target.value})}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-3 text-white"
placeholder="150.00"
required
/>
</div>
<div className="flex gap-4">
<button
type="submit"
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg transition"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddModal(false)}
className="flex-1 bg-slate-700 hover:bg-slate-600 text-white py-3 rounded-lg transition"
>
Cancel
</button>
</div>
</form>
<Panel title="Add / Update Holding">
<form onSubmit={submitHolding} className="space-y-3">
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Ticker</label>
<Input value={form.ticker} onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))} required />
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Shares</label>
<Input type="number" step="0.0001" min="0.0001" value={form.shares} onChange={(event) => setForm((prev) => ({ ...prev, shares: event.target.value }))} required />
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Average Cost</label>
<Input type="number" step="0.0001" min="0.0001" value={form.avgCost} onChange={(event) => setForm((prev) => ({ ...prev, avgCost: event.target.value }))} required />
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Current Price (optional)</label>
<Input type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
</div>
<Button type="submit" className="w-full">
<Plus className="size-4" />
Save holding
</Button>
</form>
<div className="mt-5 border-t border-[color:var(--line-weak)] pt-4">
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Latest AI Insight</p>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">
{latestInsight?.content ?? 'No insight available yet. Queue an AI brief from the header.'}
</p>
</div>
</div>
)}
</div>
</Panel>
</div>
</AppShell>
);
}

View File

@@ -1,224 +1,181 @@
'use client';
import { useSession } from '@/lib/better-auth';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ArrowRight, Eye, Plus, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusPill } from '@/components/ui/status-pill';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useTaskPoller } from '@/hooks/use-task-poller';
import { deleteWatchlistItem, getTask, listWatchlist, queueFilingSync, upsertWatchlistItem } from '@/lib/api';
import type { Task, WatchlistItem } from '@/lib/types';
type FormState = {
ticker: string;
companyName: string;
sector: string;
};
export default function WatchlistPage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [watchlist, setWatchlist] = useState([]);
const { isPending, isAuthenticated } = useAuthGuard();
const [items, setItems] = useState<WatchlistItem[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newStock, setNewStock] = useState({ ticker: '', company_name: '', sector: '' });
const [error, setError] = useState<string | null>(null);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', sector: '' });
useEffect(() => {
if (!isPending && !session) {
router.push('/auth/signin');
return;
}
const loadWatchlist = useCallback(async () => {
setLoading(true);
setError(null);
if (session?.user?.id) {
fetchWatchlist(session.user.id);
}
}, [session, isPending, router]);
const fetchWatchlist = async (userId: string) => {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/watchlist/${userId}`);
const data = await response.json();
setWatchlist(data);
} catch (error) {
console.error('Error fetching watchlist:', error);
const response = await listWatchlist();
setItems(response.items);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load watchlist');
} finally {
setLoading(false);
}
};
}, []);
const handleAddStock = async (e: React.FormEvent) => {
e.preventDefault();
const userId = session?.user?.id;
if (!userId) return;
useEffect(() => {
if (!isPending && isAuthenticated) {
void loadWatchlist();
}
}, [isPending, isAuthenticated, loadWatchlist]);
const polledTask = useTaskPoller({
taskId: activeTask?.id ?? null,
onTerminalState: () => {
setActiveTask(null);
}
});
const liveTask = polledTask ?? activeTask;
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/watchlist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
ticker: newStock.ticker.toUpperCase(),
company_name: newStock.company_name,
sector: newStock.sector
})
await upsertWatchlistItem({
ticker: form.ticker.toUpperCase(),
companyName: form.companyName,
sector: form.sector || undefined
});
setShowAddModal(false);
setNewStock({ ticker: '', company_name: '', sector: '' });
fetchWatchlist(userId);
} catch (error) {
console.error('Error adding stock:', error);
setForm({ ticker: '', companyName: '', sector: '' });
await loadWatchlist();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save watchlist item');
}
};
const handleDeleteStock = async (id: number) => {
if (!confirm('Are you sure you want to remove this stock from watchlist?')) return;
const userId = session?.user?.id;
if (!userId) return;
const queueSync = async (ticker: string) => {
try {
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/watchlist/${id}`, {
method: 'DELETE'
});
fetchWatchlist(userId);
} catch (error) {
console.error('Error deleting stock:', error);
const { task } = await queueFilingSync({ ticker, limit: 20 });
const latest = await getTask(task.id);
setActiveTask(latest.task);
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to queue sync for ${ticker}`);
}
};
if (loading) {
return <div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center">Loading...</div>;
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading watchlist terminal...</div>;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
<nav className="border-b border-slate-700 bg-slate-900/50 backdrop-blur">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
Fiscal Clone
</Link>
<div className="flex gap-4">
<Link href="/filings" className="hover:text-blue-400 transition">
Filings
</Link>
<Link href="/portfolio" className="hover:text-blue-400 transition">
Portfolio
</Link>
<Link href="/watchlist" className="hover:text-blue-400 transition">
Watchlist
</Link>
<AppShell
title="Watchlist"
subtitle="Track symbols, company context, and trigger filing ingestion jobs from one surface."
>
{liveTask ? (
<Panel title="Queue Status">
<div className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-sm text-[color:var(--terminal-bright)]">{liveTask.task_type}</p>
<StatusPill status={liveTask.status} />
</div>
</div>
</nav>
</Panel>
) : null}
<main className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Watchlist</h1>
<button
onClick={() => setShowAddModal(true)}
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
>
+ Add Stock
</button>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.6fr_1fr]">
<Panel title="Symbols" subtitle="Your monitored universe.">
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading watchlist...</p>
) : items.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No symbols yet. Add one from the right panel.</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{items.map((item) => (
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">{item.sector ?? 'Unclassified'}</p>
<h3 className="mt-1 text-xl font-semibold text-[color:var(--terminal-bright)]">{item.ticker}</h3>
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{item.company_name}</p>
</div>
<Eye className="size-4 text-[color:var(--accent)]" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{watchlist.map((stock: any) => (
<div key={stock.id} className="bg-slate-800/50 rounded-lg p-6 border border-slate-700 hover:border-slate-600 transition">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-2xl font-bold">{stock.ticker}</h3>
<p className="text-slate-400">{stock.company_name}</p>
</div>
<button
onClick={() => handleDeleteStock(stock.id)}
className="text-red-400 hover:text-red-300 transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{stock.sector && (
<div className="inline-block bg-purple-500/20 text-purple-400 px-3 py-1 rounded-full text-sm">
{stock.sector}
</div>
)}
<div className="mt-4 flex gap-2">
<Link
href={`/filings?ticker=${stock.ticker}`}
className="flex-1 bg-slate-700 hover:bg-slate-600 text-white py-2 rounded-lg text-center transition"
>
Filings
</Link>
<Link
href={`/portfolio/add?ticker=${stock.ticker}`}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg text-center transition"
>
Add to Portfolio
</Link>
</div>
</div>
))}
{watchlist.length === 0 && (
<div className="col-span-full text-center py-12 bg-slate-800/50 rounded-lg border border-slate-700">
<p className="text-slate-400 text-lg mb-4">Your watchlist is empty</p>
<p className="text-slate-500 text-sm">
Add stocks to track their SEC filings and monitor performance
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item.ticker)}>
Sync filings
</Button>
<Link
href={`/filings?ticker=${item.ticker}`}
className="inline-flex items-center gap-1 text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Open stream
<ArrowRight className="size-3" />
</Link>
<Button
variant="danger"
className="ml-auto px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteWatchlistItem(item.id);
await loadWatchlist();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove symbol');
}
}}
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
</article>
))}
</div>
)}
</div>
</main>
</Panel>
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4">
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4">Add to Watchlist</h2>
<form onSubmit={handleAddStock} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Ticker</label>
<input
type="text"
value={newStock.ticker}
onChange={(e) => setNewStock({...newStock, ticker: e.target.value})}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-3 text-white"
placeholder="AAPL"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Company Name</label>
<input
type="text"
value={newStock.company_name}
onChange={(e) => setNewStock({...newStock, company_name: e.target.value})}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-3 text-white"
placeholder="Apple Inc."
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Sector (Optional)</label>
<input
type="text"
value={newStock.sector}
onChange={(e) => setNewStock({...newStock, sector: e.target.value})}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-3 text-white"
placeholder="Technology"
/>
</div>
<div className="flex gap-4">
<button
type="submit"
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg transition"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddModal(false)}
className="flex-1 bg-slate-700 hover:bg-slate-600 text-white py-3 rounded-lg transition"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
<Panel title="Add Symbol" subtitle="Create or update a watchlist item.">
<form onSubmit={submit} className="space-y-3">
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Ticker</label>
<Input value={form.ticker} onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))} required />
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Company Name</label>
<Input value={form.companyName} onChange={(event) => setForm((prev) => ({ ...prev, companyName: event.target.value }))} required />
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Sector</label>
<Input value={form.sector} onChange={(event) => setForm((prev) => ({ ...prev, sector: event.target.value }))} />
</div>
<Button type="submit" className="w-full">
<Plus className="size-4" />
Save symbol
</Button>
</form>
</Panel>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,45 @@
import Link from 'next/link';
type AuthShellProps = {
title: string;
subtitle: string;
children: React.ReactNode;
footer: React.ReactNode;
};
export function AuthShell({ title, subtitle, children, footer }: AuthShellProps) {
return (
<div className="auth-page">
<div className="ambient-grid" aria-hidden="true" />
<div className="noise-layer" aria-hidden="true" />
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-5xl flex-col justify-center gap-8 px-4 py-10 md:px-8 lg:flex-row lg:items-center">
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-6 lg:w-[42%]">
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
<h1 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">Autonomous Analyst Desk</h1>
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-muted)]">
Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows connected to OpenClaw/ZeroClaw.
</p>
<Link
href="https://www.sec.gov/"
target="_blank"
className="mt-6 inline-flex text-xs uppercase tracking-[0.2em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
SEC Data Backbone
</Link>
</section>
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-6 shadow-[0_20px_60px_rgba(1,4,10,0.55)] lg:w-[58%]">
<h2 className="text-2xl font-semibold text-[color:var(--terminal-bright)]">{title}</h2>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
<div className="mt-6">{children}</div>
<div className="mt-6 border-t border-[color:var(--line-weak)] pt-4 text-sm text-[color:var(--terminal-muted)]">
{footer}
</div>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { cn } from '@/lib/utils';
type MetricCardProps = {
label: string;
value: string;
delta?: string;
positive?: boolean;
className?: string;
};
export function MetricCard({ label, value, delta, positive = true, className }: MetricCardProps) {
return (
<div className={cn('rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4', className)}>
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">{label}</p>
<p className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">{value}</p>
{delta ? (
<p className={cn('mt-2 text-xs', positive ? 'text-[#96f5bf]' : 'text-[#ff9898]')}>
{delta}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { formatDistanceToNow } from 'date-fns';
import type { Task } from '@/lib/types';
import { StatusPill } from '@/components/ui/status-pill';
type TaskFeedProps = {
tasks: Task[];
};
const taskLabels: Record<Task['task_type'], string> = {
sync_filings: 'Sync filings',
refresh_prices: 'Refresh prices',
analyze_filing: 'Analyze filing',
portfolio_insights: 'Portfolio insights'
};
export function TaskFeed({ tasks }: TaskFeedProps) {
if (tasks.length === 0) {
return <p className="text-sm text-[color:var(--terminal-muted)]">No recent tasks.</p>;
}
return (
<ul className="space-y-2">
{tasks.slice(0, 8).map((task) => (
<li key={task.id} className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<div>
<p className="text-sm text-[color:var(--terminal-bright)]">{taskLabels[task.task_type]}</p>
<p className="text-xs text-[color:var(--terminal-muted)]">
{formatDistanceToNow(new Date(task.created_at), { addSuffix: true })}
</p>
</div>
<StatusPill status={task.status} />
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Activity, BookOpenText, ChartCandlestick, Eye, LogOut } from 'lucide-react';
import { signOut, useSession } from '@/lib/better-auth';
import { cn } from '@/lib/utils';
type AppShellProps = {
title: string;
subtitle?: string;
actions?: React.ReactNode;
children: React.ReactNode;
};
const NAV_ITEMS = [
{ href: '/', label: 'Command Center', icon: Activity },
{ href: '/filings', label: 'Filings Stream', icon: BookOpenText },
{ href: '/portfolio', label: 'Portfolio Matrix', icon: ChartCandlestick },
{ href: '/watchlist', label: 'Watchlist', icon: Eye }
];
export function AppShell({ title, subtitle, actions, children }: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const { data: session } = useSession();
const handleSignOut = async () => {
await signOut();
router.replace('/auth/signin');
router.refresh();
};
return (
<div className="app-surface">
<div className="ambient-grid" aria-hidden="true" />
<div className="noise-layer" aria-hidden="true" />
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-[1300px] gap-6 px-4 pb-12 pt-6 md:px-8">
<aside className="hidden w-72 shrink-0 flex-col gap-6 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_0_0_1px_rgba(0,255,180,0.06),0_20px_60px_rgba(1,4,10,0.55)] lg:flex">
<div>
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
<h1 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">Neon Desk</h1>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
Financial intelligence cockpit with durable AI workflows.
</p>
</div>
<nav className="space-y-2">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-xl border px-3 py-2 text-sm transition-all duration-200',
isActive
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] shadow-[0_0_18px_rgba(0,255,180,0.16)]'
: 'border-transparent text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]'
)}
>
<Icon className="size-4" />
{item.label}
</Link>
);
})}
</nav>
<div className="mt-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Session</p>
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{session?.user?.email ?? 'anonymous'}</p>
<button
type="button"
onClick={handleSignOut}
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2 text-xs text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]"
>
<LogOut className="size-3.5" />
Sign out
</button>
</div>
</aside>
<div className="flex-1">
<header className="mb-6 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-6 py-5 shadow-[0_0_0_1px_rgba(0,255,180,0.05),0_14px_40px_rgba(1,4,10,0.5)]">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Live System</p>
<h2 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)] md:text-3xl">{title}</h2>
{subtitle ? (
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
</header>
<nav className="mb-6 flex gap-2 overflow-x-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-2 lg:hidden">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'inline-flex min-w-fit items-center gap-2 rounded-lg border px-3 py-2 text-xs transition',
isActive
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
: 'border-transparent text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)]'
)}
>
<Icon className="size-3.5" />
{item.label}
</Link>
);
})}
</nav>
<main className="space-y-6">{children}</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils';
type ButtonVariant = 'primary' | 'ghost' | 'danger' | 'secondary';
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
};
const variantMap: Record<ButtonVariant, string> = {
primary: 'border-[color:var(--line-strong)] bg-[color:var(--accent)] text-[#001515] hover:bg-[color:var(--accent-strong)]',
secondary: 'border-[color:var(--line-weak)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel)]',
ghost: 'border-[color:var(--line-weak)] bg-transparent text-[color:var(--terminal-bright)] hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]',
danger: 'border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[#ffc9c9] hover:bg-[color:var(--danger)] hover:text-[#1e0d0d]'
};
export function Button({ className, variant = 'primary', ...props }: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition duration-200 disabled:cursor-not-allowed disabled:opacity-50',
variantMap[variant],
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export function Input({ className, ...props }: InputProps) {
return (
<input
className={cn(
'w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]',
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,31 @@
import { cn } from '@/lib/utils';
type PanelProps = {
title?: string;
subtitle?: string;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
};
export function Panel({ title, subtitle, actions, children, className }: PanelProps) {
return (
<section
className={cn(
'rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_0_0_1px_rgba(0,255,180,0.03),0_12px_30px_rgba(1,4,10,0.45)]',
className
)}
>
{(title || subtitle || actions) ? (
<header className="mb-4 flex items-start justify-between gap-3">
<div>
{title ? <h3 className="text-base font-semibold text-[color:var(--terminal-bright)]">{title}</h3> : null}
{subtitle ? <p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p> : null}
</div>
{actions ? <div>{actions}</div> : null}
</header>
) : null}
{children}
</section>
);
}

View File

@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils';
import type { TaskStatus } from '@/lib/types';
type StatusPillProps = {
status: TaskStatus;
};
const classes: Record<TaskStatus, string> = {
queued: 'border-[#33587a] bg-[#0a2c3f] text-[#7ecaf5]',
running: 'border-[#4f7a33] bg-[#0f311d] text-[#99f085]',
completed: 'border-[#1a7a53] bg-[#083a2a] text-[#8bf7cb]',
failed: 'border-[#8f3d3d] bg-[#431616] text-[#ff9c9c]'
};
export function StatusPill({ status }: StatusPillProps) {
return (
<span className={cn('inline-flex items-center rounded-full border px-2 py-1 text-xs uppercase tracking-[0.16em]', classes[status])}>
{status}
</span>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from '@/lib/better-auth';
export function useAuthGuard() {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && !session) {
router.replace('/auth/signin');
}
}, [isPending, session, router]);
return {
session,
isPending,
isAuthenticated: Boolean(session?.user)
};
}

View File

@@ -0,0 +1,59 @@
'use client';
import { useEffect, useState } from 'react';
import { getTask } from '@/lib/api';
import type { Task } from '@/lib/types';
type UseTaskPollerInput = {
taskId: string | null;
intervalMs?: number;
onTerminalState?: (task: Task) => void;
};
export function useTaskPoller({ taskId, intervalMs = 2200, onTerminalState }: UseTaskPollerInput) {
const [task, setTask] = useState<Task | null>(null);
useEffect(() => {
if (!taskId) {
setTask(null);
return;
}
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const { task: latest } = await getTask(taskId);
if (stopped) {
return;
}
setTask(latest);
if (latest.status === 'completed' || latest.status === 'failed') {
onTerminalState?.(latest);
return;
}
} catch {
if (stopped) {
return;
}
}
timer = setTimeout(poll, intervalMs);
};
void poll();
return () => {
stopped = true;
if (timer) {
clearTimeout(timer);
}
};
}, [taskId, intervalMs, onTerminalState]);
return task;
}

144
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,144 @@
import type {
Filing,
Holding,
PortfolioInsight,
PortfolioSummary,
Task,
User,
WatchlistItem
} from './types';
import { resolveApiBaseURL } from './runtime-url';
const API_BASE = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL);
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers,
cache: 'no-store'
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof body?.error === 'string' ? body.error : `Request failed (${response.status})`;
throw new ApiError(message, response.status);
}
return body as T;
}
export async function getMe() {
return await apiFetch<{ user: User }>('/api/me');
}
export async function listWatchlist() {
return await apiFetch<{ items: WatchlistItem[] }>('/api/watchlist');
}
export async function upsertWatchlistItem(input: { ticker: string; companyName: string; sector?: string }) {
return await apiFetch<{ item: WatchlistItem }>('/api/watchlist', {
method: 'POST',
body: JSON.stringify(input)
});
}
export async function deleteWatchlistItem(id: number) {
return await apiFetch<{ success: boolean }>(`/api/watchlist/${id}`, {
method: 'DELETE'
});
}
export async function listHoldings() {
return await apiFetch<{ holdings: Holding[] }>('/api/portfolio/holdings');
}
export async function getPortfolioSummary() {
return await apiFetch<{ summary: PortfolioSummary }>('/api/portfolio/summary');
}
export async function upsertHolding(input: {
ticker: string;
shares: number;
avgCost: number;
currentPrice?: number;
}) {
return await apiFetch<{ holding: Holding }>('/api/portfolio/holdings', {
method: 'POST',
body: JSON.stringify(input)
});
}
export async function deleteHolding(id: number) {
return await apiFetch<{ success: boolean }>(`/api/portfolio/holdings/${id}`, {
method: 'DELETE'
});
}
export async function queuePriceRefresh() {
return await apiFetch<{ task: Task }>('/api/portfolio/refresh-prices', {
method: 'POST'
});
}
export async function queuePortfolioInsights() {
return await apiFetch<{ task: Task }>('/api/portfolio/insights/generate', {
method: 'POST'
});
}
export async function getLatestPortfolioInsight() {
return await apiFetch<{ insight: PortfolioInsight | null }>('/api/portfolio/insights/latest');
}
export async function listFilings(query?: { ticker?: string; limit?: number }) {
const params = new URLSearchParams();
if (query?.ticker) {
params.set('ticker', query.ticker);
}
if (query?.limit) {
params.set('limit', String(query.limit));
}
const suffix = params.size > 0 ? `?${params.toString()}` : '';
return await apiFetch<{ filings: Filing[] }>(`/api/filings${suffix}`);
}
export async function queueFilingSync(input: { ticker: string; limit?: number }) {
return await apiFetch<{ task: Task }>('/api/filings/sync', {
method: 'POST',
body: JSON.stringify(input)
});
}
export async function queueFilingAnalysis(accessionNumber: string) {
return await apiFetch<{ task: Task }>(`/api/filings/${accessionNumber}/analyze`, {
method: 'POST'
});
}
export async function getTask(taskId: string) {
return await apiFetch<{ task: Task }>(`/api/tasks/${taskId}`);
}
export async function listRecentTasks(limit = 20) {
return await apiFetch<{ tasks: Task[] }>(`/api/tasks?limit=${limit}`);
}

View File

@@ -1,12 +0,0 @@
import { authClient } from '@/lib/better-auth';
import { redirect } from 'next/navigation';
export async function requireAuth() {
const { data: session } = await authClient.getSession();
if (!session || !session.user) {
redirect('/auth/signin');
}
return session;
}

View File

@@ -1,7 +1,11 @@
import { createAuthClient } from "better-auth/react";
import { createAuthClient } from 'better-auth/react';
import { resolveApiBaseURL } from '@/lib/runtime-url';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
baseURL: resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL),
fetchOptions: {
credentials: 'include'
}
});
export const { signIn, signUp, signOut, useSession } = authClient;

29
frontend/lib/format.ts Normal file
View File

@@ -0,0 +1,29 @@
export function asNumber(value: string | number | null | undefined) {
if (value === null || value === undefined) {
return 0;
}
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function formatCurrency(value: string | number | null | undefined) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2
}).format(asNumber(value));
}
export function formatPercent(value: string | number | null | undefined) {
return `${asNumber(value).toFixed(2)}%`;
}
export function formatCompactCurrency(value: string | number | null | undefined) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
notation: 'compact',
maximumFractionDigits: 2
}).format(asNumber(value));
}

View File

@@ -0,0 +1,45 @@
function trimTrailingSlash(value: string) {
return value.endsWith('/') ? value.slice(0, -1) : value;
}
function isInternalHost(hostname: string) {
return hostname === 'backend'
|| hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname.endsWith('.internal');
}
function parseUrl(url: string) {
try {
return new URL(url);
} catch {
return null;
}
}
export function resolveApiBaseURL(configuredBaseURL: string | undefined) {
const fallbackLocal = 'http://localhost:3001';
const candidate = configuredBaseURL?.trim() || fallbackLocal;
if (typeof window === 'undefined') {
return trimTrailingSlash(candidate);
}
const parsed = parseUrl(candidate);
if (!parsed) {
return `${window.location.origin}`;
}
const browserHost = window.location.hostname;
const browserIsLocal = browserHost === 'localhost' || browserHost === '127.0.0.1';
if (!browserIsLocal && isInternalHost(parsed.hostname)) {
console.warn(
`[fiscal] NEXT_PUBLIC_API_URL is internal (${parsed.hostname}); falling back to https://api.${browserHost}`
);
return trimTrailingSlash(`https://api.${browserHost}`);
}
return trimTrailingSlash(parsed.toString());
}

90
frontend/lib/types.ts Normal file
View File

@@ -0,0 +1,90 @@
export type User = {
id: number;
email: string;
name: string | null;
image: string | null;
};
export type WatchlistItem = {
id: number;
user_id: number;
ticker: string;
company_name: string;
sector: string | null;
created_at: string;
};
export type Holding = {
id: number;
user_id: number;
ticker: string;
shares: string;
avg_cost: string;
current_price: string | null;
market_value: string;
gain_loss: string;
gain_loss_pct: string;
last_price_at: string | null;
created_at: string;
updated_at: string;
};
export type PortfolioSummary = {
positions: number;
total_value: string;
total_gain_loss: string;
total_cost_basis: string;
avg_return_pct: string;
};
export type Filing = {
id: number;
ticker: string;
filing_type: '10-K' | '10-Q' | '8-K';
filing_date: string;
accession_number: string;
cik: string;
company_name: string;
filing_url: string | null;
metrics: {
revenue: number | null;
netIncome: number | null;
totalAssets: number | null;
cash: number | null;
debt: number | null;
} | null;
analysis: {
provider?: string;
model?: string;
text?: string;
legacyInsights?: string;
} | null;
created_at: string;
updated_at: string;
};
export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed';
export type Task = {
id: string;
task_type: 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights';
status: TaskStatus;
priority: number;
payload: Record<string, unknown>;
result: Record<string, unknown> | null;
error: string | null;
attempts: number;
max_attempts: number;
created_at: string;
updated_at: string;
finished_at: string | null;
};
export type PortfolioInsight = {
id: number;
user_id: number;
provider: string;
model: string;
content: string;
created_at: string;
};

View File

@@ -1,6 +1,5 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { clsx, type ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
export function cn(...values: ClassValue[]) {
return clsx(values);
}

View File

@@ -4,7 +4,19 @@ const nextConfig = {
output: 'standalone',
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
},
async headers() {
return [
{
source: '/auth/:path*',
headers: [
{ key: 'Cache-Control', value: 'no-store, no-cache, max-age=0, must-revalidate' },
{ key: 'Pragma', value: 'no-cache' },
{ key: 'Expires', value: '0' }
]
}
];
}
}
module.exports = nextConfig
module.exports = nextConfig;

View File

@@ -1,6 +1,7 @@
{
"name": "fiscal-frontend",
"version": "0.1.0",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -8,21 +9,14 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.574.0",
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.7.0",
"tailwind-merge": "^3.5.0"
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/node": "^25.3.0",