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 DATABASE_URL=postgres://postgres:postgres@localhost:5432/fiscal
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_DB=fiscal POSTGRES_DB=fiscal
POSTGRES_HOST=localhost POSTGRES_HOST=localhost
# Backend # API service
PORT=3001 PORT=3001
NODE_ENV=development 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 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 # Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001 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 / ZeroClaw (OpenAI-compatible)
OPENCLAW_WEBHOOK_URL=https://discord.com/api/webhooks/... 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 This document described the pre-2.0 incremental migration path.
Migrated from NextAuth v5 (beta) to Better Auth for unified authentication across both Elysia (backend) and Next.js (frontend).
## Backend Changes The codebase has been rebuilt for Fiscal Clone 2.0. Use these sources instead:
- `README.md` for runtime and setup
### Installation - `backend/src/auth.ts` for Better Auth configuration
- Added `better-auth@1.4.18` package - `backend/src/db/migrate.ts` for current schema
- Added `pg@8.18.0` for PostgreSQL connection pool - `docs/REBUILD_DECISIONS.md` for architecture rationale
### 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

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 - `frontend` (Next.js)
2. GitHub repository with this code - `backend` (Elysia API + Better Auth)
3. PostgreSQL database - `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 1. Create a **Docker Compose** app in Coolify.
2. Connect your GitHub repository 2. Connect this repository.
3. Select the `docker-compose.yml` file in the root 3. Use compose file: `/docker-compose.yml`.
4. Configure environment variables: 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`)
``` ## Required environment variables
DATABASE_URL=postgres://postgres:your_password@postgres:5432/fiscal
Set these in Coolify before deploy:
```env
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_password POSTGRES_PASSWORD=<strong-password>
POSTGRES_DB=fiscal POSTGRES_DB=fiscal
PORT=3001
BETTER_AUTH_SECRET=your-random-long-secret DATABASE_URL=postgres://postgres:<strong-password>@postgres:5432/fiscal
BETTER_AUTH_BASE_URL=https://api.your-fiscal-domain.com
JWT_SECRET=your-jwt-secret-key-min-32-characters # Public URLs
GITHUB_ID=your-github-oauth-client-id FRONTEND_URL=https://fiscal.example.com
GITHUB_SECRET=your-github-oauth-client-secret BETTER_AUTH_BASE_URL=https://api.fiscal.example.com
GOOGLE_ID=your-google-oauth-client-id NEXT_PUBLIC_API_URL=https://api.fiscal.example.com
GOOGLE_SECRET=your-google-oauth-client-secret
NEXT_PUBLIC_API_URL=https://api.your-fiscal-domain.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 ## Post-deploy checks
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:
1. API health:
```bash ```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: - `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.
1. Add to `/data/workspace/memory/coolify-integration.md` - `BETTER_AUTH_BASE_URL` must be the public backend URL, not the internal container hostname.
2. Set up Discord alerts for critical issues - 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`).
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

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 This file is retained only as a compatibility entrypoint.
**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

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** - Authentication (email/password via Better Auth)
- 10-K, 10-Q, 8-K filings support - Watchlist management
- Key metrics extraction (revenue, net income, assets, cash, debt) - SEC filings ingestion (10-K, 10-Q, 8-K)
- Real-time search and updates - 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** ## Architecture
- Stock holdings tracking
- Real-time price updates (Yahoo Finance API)
- Automatic P&L calculations
- Performance charts (pie chart allocation, line chart performance)
- **Watchlist Management** - `frontend/`: Next.js App Router UI
- Add/remove stocks to watchlist - `backend/`: Elysia API + Better Auth + domain routes
- Track company and sector information - `backend/src/worker.ts`: durable queue worker
- Quick access to filings and portfolio - `docs/REBUILD_DECISIONS.md`: one-by-one architecture decisions
- **Authentication** Runtime topology:
- NextAuth.js with multiple providers 1. Frontend web app
- GitHub, Google OAuth, Email/Password 2. Backend API
- JWT-based session management with 30-day expiration 3. Worker process for long tasks
4. PostgreSQL
- **OpenClaw Integration** ## Local Setup
- 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
```bash ```bash
# Clone repository cp .env.example .env
git clone https://git.b11studio.xyz/francy51/fiscal-clone.git ```
cd fiscal-clone
# Install backend dependencies ### 1) Backend
```bash
cd backend cd backend
bun install bun install
bun run db:migrate
# Install frontend dependencies bun run dev
cd frontend
npm install
# Copy environment variables
cp .env.example .env
# Edit .env with your configuration
nano .env
``` ```
### Environment Variables ### 2) Worker (new terminal)
```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
```bash ```bash
# Run database migrations
cd backend cd backend
bun run db:migrate bun run dev:worker
```
# Start backend ### 3) Frontend (new terminal)
cd backend
bun run dev
# Start frontend (new terminal) ```bash
cd frontend cd frontend
npm install
npm run dev 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 ```bash
# Initialize git docker compose up --build
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
``` ```
### 2. Deploy to Coolify This starts: `postgres`, `backend`, `worker`, `frontend`.
In Coolify dashboard: ## Coolify
1. **Create Application** Deploy using the root compose file and configure separate public domains for:
- Type: Docker Compose - `frontend` on port `3000`
- Name: `fiscal-clone` - `backend` on port `3001`
- Source: Git Repository
- Repository: `git@git.b11studio.xyz:francy51/fiscal-clone.git`
- Branch: `main`
- Build Context: `/`
- Docker Compose File: `docker-compose.yml`
2. **Configure Domains** Use the full guide in `COOLIFY.md`.
- Frontend: `fiscal.b11studio.xyz`
- Backend API: `api.fiscal.b11studio.xyz`
3. **Add Environment Variables** Critical variables for Coolify:
``` - `FRONTEND_URL` = frontend public URL
DATABASE_URL=postgres://postgres:password@postgres:5432/fiscal - `BETTER_AUTH_BASE_URL` = backend public URL
POSTGRES_USER=postgres - `NEXT_PUBLIC_API_URL` = backend public URL (build-time in frontend)
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
```
4. **Deploy** ## Core API Surface
## API Endpoints Auth:
- `ALL /api/auth/*` (Better Auth handler)
- `GET /api/me`
### Authentication Watchlist:
- `POST /api/auth/register` - Register new user - `GET /api/watchlist`
- `POST /api/auth/login` - Login - `POST /api/watchlist`
- `POST /api/auth/verify` - Verify JWT token - `DELETE /api/watchlist/:id`
### SEC Filings Portfolio:
- `GET /api/filings` - Get all filings - `GET /api/portfolio/holdings`
- `GET /api/filings/:ticker` - Get filings by ticker - `POST /api/portfolio/holdings`
- `POST /api/filings/refresh/:ticker` - Refresh filings - `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 Filings:
- `GET /api/portfolio/:userId` - Get portfolio - `GET /api/filings?ticker=&limit=`
- `GET /api/portfolio/:userId/summary` - Get summary - `GET /api/filings/:accessionNumber`
- `POST /api/portfolio` - Add holding - `POST /api/filings/sync` (queues task)
- `PUT /api/portfolio/:id` - Update holding - `POST /api/filings/:accessionNumber/analyze` (queues task)
- `DELETE /api/portfolio/:id` - Delete holding
### Watchlist Task tracking:
- `GET /api/watchlist/:userId` - Get watchlist - `GET /api/tasks`
- `POST /api/watchlist` - Add stock - `GET /api/tasks/:taskId`
- `DELETE /api/watchlist/:id` - Remove stock
### OpenClaw Integration ## OpenClaw / ZeroClaw Integration
- `POST /api/openclaw/notify/filing` - Discord notification
- `POST /api/openclaw/insights/portfolio` - Portfolio analysis
- `POST /api/openclaw/insights/filing` - Filing analysis
## Database Schema Set these in `.env`:
### Users ```env
```sql OPENCLAW_BASE_URL=http://localhost:4000
CREATE TABLE users ( OPENCLAW_API_KEY=...
id SERIAL PRIMARY KEY, OPENCLAW_MODEL=zeroclaw
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
);
``` ```
### Filings The backend expects an OpenAI-compatible `/v1/chat/completions` endpoint.
```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
);
```
### Portfolio (with auto-calculations) ## Decision Log
```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)
);
```
### Watchlist See `docs/REBUILD_DECISIONS.md` for the detailed rationale and tradeoffs behind each major design choice.
```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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,38 @@
import { betterAuth } from "better-auth"; import { betterAuth } from 'better-auth';
import { Pool } from "pg"; 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 pool = new Pool({
const defaultFrontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; connectionString: env.DATABASE_URL,
const trustedOrigins = defaultFrontendUrl max: 20,
.split(',') idleTimeoutMillis: 30_000
.map((origin) => origin.trim()) });
.filter(Boolean);
export const auth = betterAuth({ export const auth = betterAuth({
database: new Pool({ secret: env.BETTER_AUTH_SECRET,
connectionString: process.env.DATABASE_URL || defaultDatabaseUrl, baseURL: env.BETTER_AUTH_BASE_URL,
}), database: pool,
trustedOrigins, trustedOrigins: env.FRONTEND_ORIGINS,
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
autoSignIn: true, autoSignIn: true
}, },
user: { user: {
modelName: "users", modelName: 'users',
additionalFields: { additionalFields: {
name: { name: {
type: "string", type: 'string',
required: false, required: false
},
}, },
image: {
type: 'string',
required: false
}
}
}, },
advanced: { advanced: {
database: { 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 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'}`; export const db = postgres(env.DATABASE_URL, {
max: 20,
const sql = postgres(process.env.DATABASE_URL || defaultDatabaseUrl, {
max: 10,
idle_timeout: 20, idle_timeout: 20,
connect_timeout: 10 connect_timeout: 10,
prepare: true
}); });
export const db = sql; export async function closeDb() {
await db.end({ timeout: 5 });
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;
};

View File

@@ -1,107 +1,256 @@
import { db } from './index'; import { db } from './index';
async function migrate() { 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` await db`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, email_verified BOOLEAN NOT NULL DEFAULT FALSE,
name VARCHAR(255), name TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, image TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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` await db`
CREATE TABLE IF NOT EXISTS filings ( CREATE TABLE IF NOT EXISTS filings (
id SERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
ticker VARCHAR(10) NOT NULL, ticker VARCHAR(12) NOT NULL,
filing_type VARCHAR(20) NOT NULL, filing_type VARCHAR(20) NOT NULL,
filing_date DATE 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, cik VARCHAR(20) NOT NULL,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
key_metrics JSONB, filing_url TEXT,
insights TEXT, metrics JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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` await db`
CREATE TABLE IF NOT EXISTS portfolio ( DO $$
id SERIAL PRIMARY KEY, BEGIN
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, IF EXISTS (
ticker VARCHAR(10) NOT NULL, SELECT 1
shares NUMERIC(20, 4) NOT NULL, FROM information_schema.columns
avg_cost NUMERIC(10, 4) NOT NULL, WHERE table_name = 'filings'
current_price NUMERIC(10, 4), AND column_name = 'key_metrics'
current_value NUMERIC(20, 4), ) THEN
gain_loss NUMERIC(20, 4), EXECUTE 'UPDATE filings SET metrics = COALESCE(metrics, key_metrics) WHERE metrics IS NULL';
gain_loss_pct NUMERIC(10, 4), END IF;
last_updated TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, IF EXISTS (
UNIQUE(user_id, ticker) 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` await db`
CREATE TABLE IF NOT EXISTS watchlist ( CREATE TABLE IF NOT EXISTS watchlist (
id SERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticker VARCHAR(10) NOT NULL, ticker VARCHAR(12) NOT NULL,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
sector VARCHAR(100), sector VARCHAR(120),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, ticker) 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` await db`
CREATE OR REPLACE FUNCTION update_portfolio_prices() CREATE TABLE IF NOT EXISTS holdings (
RETURNS TRIGGER AS $$ id BIGSERIAL PRIMARY KEY,
BEGIN user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
NEW.current_value := NEW.shares * NEW.current_price; ticker VARCHAR(12) NOT NULL,
NEW.gain_loss := NEW.current_value - (NEW.shares * NEW.avg_cost); shares NUMERIC(20, 4) NOT NULL,
NEW.gain_loss_pct := CASE avg_cost NUMERIC(12, 4) NOT NULL,
WHEN NEW.avg_cost > 0 THEN ((NEW.current_price - NEW.avg_cost) / NEW.avg_cost) * 100 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 ELSE 0
END; END
NEW.last_updated := NOW(); ) STORED,
RETURN NEW; last_price_at TIMESTAMPTZ,
END; created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
$$ LANGUAGE plpgsql; updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, ticker)
)
`; `;
// Create trigger
await db` await db`
DROP TRIGGER IF EXISTS update_portfolio_prices_trigger ON portfolio DO $$
`; BEGIN
await db` IF EXISTS (
CREATE TRIGGER update_portfolio_prices_trigger SELECT 1
BEFORE INSERT OR UPDATE ON portfolio FROM information_schema.tables
FOR EACH ROW WHERE table_name = 'portfolio'
EXECUTE FUNCTION update_portfolio_prices() ) 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
$$
`; `;
console.log('✅ Migrations completed!'); await db`
process.exit(0); 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()
)
`;
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 => { migrate()
console.error('❌ Migration failed:', error); .then(() => process.exit(0))
.catch((error) => {
console.error('Migration failed', error);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,51 +1,58 @@
import { Elysia } from 'elysia'; import { Elysia } from 'elysia';
import { cors } from '@elysiajs/cors'; import { cors } from '@elysiajs/cors';
import { swagger } from '@elysiajs/swagger'; import { swagger } from '@elysiajs/swagger';
import * as dotenv from 'dotenv'; import { env } from './config';
dotenv.config();
import { db } from './db'; 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 { 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({ .use(cors({
origin: frontendOrigin, origin: env.FRONTEND_ORIGINS,
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] allowedHeaders: ['Content-Type', 'Authorization'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
})) }))
.use(swagger({ .use(swagger({
documentation: { documentation: {
info: { info: {
title: 'Fiscal Clone API', title: 'Fiscal Clone API',
version: '1.0.0', version: '2.0.0',
description: 'Financial filings and portfolio analytics API' description: 'Futuristic fiscal intelligence API with durable jobs and OpenClaw integration.'
} }
} }
})) }))
.use(betterAuthRoutes) .use(betterAuthRoutes)
.use(filingsRoutes) .use(meRoutes)
.use(portfolioRoutes)
.use(watchlistRoutes) .use(watchlistRoutes)
.use(portfolioRoutes)
.use(filingsRoutes)
.use(openclawRoutes) .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 return {
.get('/health', () => ({
status: 'ok', status: 'ok',
version: '2.0.0',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: '1.0.0', queue: queueRows.reduce<Record<string, number>>((acc, row) => {
database: 'connected' 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(`Fiscal backend listening on http://localhost:${app.server?.port}`);
console.log(`📚 Swagger docs: http://localhost:${app.server?.port}/swagger`); 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 { Elysia } from 'elysia';
import { auth } from '../auth'; import { auth } from '../auth';
export const betterAuthRoutes = new Elysia() export const betterAuthRoutes = new Elysia({ prefix: '/auth' })
.all('/auth/*', async ({ request }) => { .all('/*', async ({ request }) => {
return auth.handler(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 { Elysia, t } from 'elysia';
import { SECScraper } from '../services/sec';
import { db } from '../db'; import { db } from '../db';
import { requireSessionUser } from '../session';
const sec = new SECScraper(); import { enqueueTask } from '../tasks/repository';
import { toHttpError } from './error';
export const filingsRoutes = new Elysia({ prefix: '/filings' }) export const filingsRoutes = new Elysia({ prefix: '/filings' })
.get('/', async () => { .get('/', async ({ request, set, query }) => {
const filings = await db` try {
SELECT * FROM filings await requireSessionUser(request);
ORDER BY filing_date DESC const tickerFilter = query.ticker?.trim().toUpperCase();
LIMIT 100 const limit = Number(query.limit ?? 50);
`; const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 200) : 50;
return filings;
})
.get('/:ticker', async ({ params }) => { const rows = tickerFilter
const filings = await db` ? await db`
SELECT * FROM filings SELECT *
WHERE ticker = ${params.ticker.toUpperCase()} FROM filings
ORDER BY filing_date DESC WHERE ticker = ${tickerFilter}
LIMIT 50 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}
`; `;
return filings;
})
.get('/details/:accessionNumber', async ({ params }) => { return { filings: rows };
const details = await db` } catch (error) {
SELECT * FROM filings 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} WHERE accession_number = ${params.accessionNumber}
LIMIT 1
`; `;
return details[0] || null;
})
.post('/refresh/:ticker', async ({ params }) => { if (!rows[0]) {
const newFilings = await sec.searchFilings(params.ticker, 5); set.status = 404;
return { error: 'Filing not found' };
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 { success: true, count: newFilings.length }; 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 { 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 { export const openclawRoutes = new Elysia({ prefix: '/ai' })
text: string; .get('/status', async ({ request, set }) => {
channelId?: string; try {
} await requireSessionUser(request);
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 { return {
health: 'moderate', configured: Boolean(env.OPENCLAW_BASE_URL && env.OPENCLAW_API_KEY),
risk: 'medium', baseUrl: env.OPENCLAW_BASE_URL ?? null,
recommendations: [ model: env.OPENCLAW_MODEL
'Consider diversifying sector exposure',
'Review underperforming positions',
'Rebalance portfolio'
],
analysis: 'Portfolio shows mixed performance with some concentration risk.'
}; };
}, { } catch (error) {
body: t.Object({ return toHttpError(set, error);
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' };
} }
})
.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 = ` return { task };
Analyze this SEC filing: } 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} return { task };
**Ticker:** ${filing.ticker} } catch (error) {
**Type:** ${filing.filing_type} return toHttpError(set, error);
**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
};
}, { }, {
body: t.Object({ body: t.Object({
accessionNumber: t.String() accessionNumber: t.String({ minLength: 8 })
}) })
}); });

View File

@@ -1,65 +1,197 @@
import { Elysia, t } from 'elysia'; 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' }) export const portfolioRoutes = new Elysia({ prefix: '/portfolio' })
.get('/:userId', async ({ params }) => { .get('/holdings', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const holdings = await db` const holdings = await db`
SELECT * FROM portfolio SELECT
WHERE user_id = ${params.userId} id,
ORDER BY ticker 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 rows = await db`
const result = await db` INSERT INTO holdings (
INSERT INTO portfolio ${db(body as Portfolio)} user_id,
ticker,
shares,
avg_cost,
current_price
) VALUES (
${user.id},
${ticker},
${body.shares},
${body.avgCost},
${body.currentPrice ?? null}
)
ON CONFLICT (user_id, ticker) ON CONFLICT (user_id, ticker)
DO UPDATE SET DO UPDATE SET
shares = EXCLUDED.shares, shares = EXCLUDED.shares,
avg_cost = EXCLUDED.avg_cost, avg_cost = EXCLUDED.avg_cost,
current_price = EXCLUDED.current_price current_price = COALESCE(EXCLUDED.current_price, holdings.current_price),
updated_at = NOW()
RETURNING * RETURNING *
`; `;
return result[0]; return { holding: rows[0] };
} catch (error) {
return toHttpError(set, error);
}
}, { }, {
body: t.Object({ body: t.Object({
user_id: t.String(), ticker: t.String({ minLength: 1, maxLength: 12 }),
ticker: t.String(), shares: t.Number({ minimum: 0.0001 }),
shares: t.Number(), avgCost: t.Number({ minimum: 0.0001 }),
avg_cost: t.Number(), currentPrice: t.Optional(t.Number({ minimum: 0 }))
current_price: t.Optional(t.Number())
}) })
}) })
.patch('/holdings/:id', async ({ request, set, params, body }) => {
.put('/:id', async ({ params, body }) => { try {
const result = await db` const user = await requireSessionUser(request);
UPDATE portfolio const rows = await db`
SET ${db(body)} 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} WHERE id = ${params.id}
AND user_id = ${user.id}
RETURNING * RETURNING *
`; `;
return result[0] || null; if (!rows[0]) {
}) set.status = 404;
return { error: 'Holding not found' };
}
.delete('/:id', async ({ params }) => { return { holding: rows[0] };
await db`DELETE FROM portfolio WHERE id = ${params.id}`; } catch (error) {
return { success: true }; 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 }))
}) })
})
.get('/:userId/summary', async ({ params }) => { .delete('/holdings/:id', async ({ request, set, params }) => {
const summary = await db` try {
SELECT const user = await requireSessionUser(request);
COUNT(*) as total_positions, const rows = await db`
COALESCE(SUM(current_value), 0) as total_value, DELETE FROM holdings
COALESCE(SUM(gain_loss), 0) as total_gain_loss, WHERE id = ${params.id}
COALESCE(SUM(current_value) - SUM(shares * avg_cost), 0) as cost_basis AND user_id = ${user.id}
FROM portfolio RETURNING id
WHERE user_id = ${params.userId}
`; `;
return summary[0]; 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}
`;
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 { 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 { Elysia, t } from 'elysia';
import { db } from '../db'; import { db } from '../db';
import { requireSessionUser } from '../session';
import { toHttpError } from './error';
export const watchlistRoutes = new Elysia({ prefix: '/watchlist' }) export const watchlistRoutes = new Elysia({ prefix: '/watchlist' })
.get('/:userId', async ({ params }) => { .get('/', async ({ request, set }) => {
try {
const user = await requireSessionUser(request);
const watchlist = await db` const watchlist = await db`
SELECT * FROM watchlist SELECT id, user_id, ticker, company_name, sector, created_at
WHERE user_id = ${params.userId} FROM watchlist
WHERE user_id = ${user.id}
ORDER BY created_at DESC 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 rows = await db`
const result = await db` INSERT INTO watchlist (
INSERT INTO watchlist ${db(body)} user_id,
ON CONFLICT (user_id, ticker) DO NOTHING 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 * RETURNING *
`; `;
return result[0]; return { item: rows[0] };
} catch (error) {
return toHttpError(set, error);
}
}, { }, {
body: t.Object({ body: t.Object({
user_id: t.String(), ticker: t.String({ minLength: 1, maxLength: 12 }),
ticker: t.String(), companyName: t.String({ minLength: 1, maxLength: 200 }),
company_name: t.String(), sector: t.Optional(t.String({ maxLength: 120 }))
sector: t.Optional(t.String())
}) })
}) })
.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
`;
if (!rows[0]) {
set.status = 404;
return { error: 'Watchlist item not found' };
}
.delete('/:id', async ({ params }) => {
await db`DELETE FROM watchlist WHERE id = ${params.id}`;
return { success: true }; 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 { 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 { try {
const response = await fetch( const response = await fetch(`${YAHOO_BASE}/${normalizedTicker}?interval=1d&range=1d`, {
`${this.baseUrl}/${ticker}?interval=1d&range=1d`,
{
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/1.0)' 'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/2.0)'
} }
} });
);
if (!response.ok) return null; if (!response.ok) {
const data = await response.json();
const result = data.chart?.result?.[0];
if (!result?.meta?.regularMarketPrice) {
return null; return null;
} }
return result.meta.regularMarketPrice; const payload = await response.json() as {
} catch (error) { chart?: {
console.error(`Error fetching price for ${ticker}:`, error); result?: Array<{ meta?: { regularMarketPrice?: number } }>;
};
};
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
return typeof price === 'number' ? price : null;
} catch {
return null; return null;
} }
} }
/** async refreshHoldingsPrices(userId?: number) {
* Get historical prices const holdings = userId
*/ ? await db`SELECT DISTINCT ticker FROM holdings WHERE user_id = ${userId}`
async getHistoricalPrices(ticker: string, period: string = '1y'): Promise<Array<{ date: string, price: number }>> { : await db`SELECT DISTINCT ticker FROM holdings`;
try {
const response = await fetch(
`${this.baseUrl}/${ticker}?interval=1d&range=${period}`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/1.0)'
}
}
);
if (!response.ok) return []; let updatedCount = 0;
const data = await response.json(); for (const holding of holdings) {
const result = data.chart?.result?.[0]; const price = await this.getQuote(holding.ticker);
if (!result?.timestamp || !result?.indicators?.quote?.[0]?.close) { if (price === null) {
return []; continue;
} }
const timestamps = result.timestamp; if (userId) {
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) {
await db` await db`
UPDATE portfolio UPDATE holdings
SET current_price = ${price} SET current_price = ${price}, last_price_at = NOW(), updated_at = NOW()
WHERE ticker = ${ticker} 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 updatedCount += 1;
await new Promise(resolve => setTimeout(resolve, 100));
await Bun.sleep(120);
} }
console.log(`Updated ${updated} stock prices`); return {
} updatedCount,
totalTickers: holdings.length
/** };
* 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;
} }
} }

View File

@@ -1,162 +1,208 @@
import { type Filings } from '../db'; import { env } from '../config';
import type { FilingMetrics, FilingType } from '../types';
export class SECScraper { type TickerDirectoryRecord = {
private baseUrl = 'https://www.sec.gov'; cik_str: number;
private userAgent = 'Fiscal Clone (contact@example.com)'; ticker: string;
title: string;
};
/** type RecentFilingsPayload = {
* Search SEC filings by ticker filings?: {
*/ recent?: {
async searchFilings(ticker: string, count = 20): Promise<Filings[]> { accessionNumber?: string[];
const cik = await this.getCIK(ticker); filingDate?: string[];
form?: string[];
primaryDocument?: string[];
};
};
cik?: string;
name?: string;
};
const response = await fetch( type CompanyFactsPayload = {
`https://data.sec.gov/submissions/CIK${cik.padStart(10, '0')}.json`, 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: { headers: {
'User-Agent': this.userAgent 'User-Agent': env.SEC_USER_AGENT,
Accept: 'application/json'
} }
}
);
if (!response.ok) {
throw new Error(`SEC API error: ${response.status}`);
}
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;
}
/**
* Check for new filings and save to database
*/
async checkNewFilings(db: any) {
const tickers = await db`
SELECT DISTINCT ticker FROM watchlist
`;
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');
}
const data = await response.json();
const companies = data.data;
for (const [cik, company] of Object.entries(companies)) {
if (company.ticker === ticker.toUpperCase()) {
return cik;
}
}
throw new Error(`Ticker ${ticker} not found`);
}
/**
* 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`;
const response = await fetch(filingUrl, {
headers: { 'User-Agent': this.userAgent }
}); });
if (!response.ok) return null; if (!response.ok) {
throw new Error(`SEC request failed (${response.status}) for ${url}`);
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;
}
} }
/** return await response.json() as T;
* 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;
} }
/** private async ensureTickerCache() {
* Get filing details by accession number const isFresh = Date.now() - this.tickerCacheLoadedAt < TICKER_CACHE_TTL_MS;
*/
async getFilingDetails(accessionNumber: string) { if (isFresh && this.tickerCache.size > 0) {
const filingUrl = `${this.baseUrl}/Archives/${accessionNumber.replace(/-/g, '')}/${accessionNumber}-index.htm`; return;
}
const payload = await this.fetchJson<Record<string, TickerDirectoryRecord>>('https://www.sec.gov/files/company_tickers.json');
const nextCache = new Map<string, TickerDirectoryRecord>();
for (const record of Object.values(payload)) {
nextCache.set(record.ticker.toUpperCase(), record);
}
this.tickerCache = nextCache;
this.tickerCacheLoadedAt = Date.now();
}
async resolveTicker(ticker: string) {
await this.ensureTickerCache();
const normalizedTicker = ticker.trim().toUpperCase();
const record = this.tickerCache.get(normalizedTicker);
if (!record) {
throw new Error(`Ticker ${normalizedTicker} was not found in SEC directory`);
}
return { 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: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
expose: expose:
- "5432" - '5432'
healthcheck: 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 interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -21,6 +21,7 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
command: ['sh', '-c', 'bun run src/db/migrate.ts && bun run src/index.ts']
env_file: env_file:
- path: ./.env - path: ./.env
required: false required: false
@@ -30,33 +31,69 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal} DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal}
PORT: ${PORT:-3001} PORT: ${PORT:-3001}
POSTGRES_HOST: postgres 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_SECRET: ${BETTER_AUTH_SECRET:-local-dev-better-auth-secret-change-me}
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3001} 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: expose:
- "3001" - '3001'
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 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: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: 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 restart: unless-stopped
environment: environment:
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 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: expose:
- "3000" - '3000'
depends_on: depends_on:
- backend - 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'; 'use client';
import { signIn, useSession } from '@/lib/better-auth'; export const dynamic = 'force-dynamic';
import { useRouter } from 'next/navigation'; export const revalidate = 0;
import { useEffect, useState } from 'react';
export default function SignIn() { import Link from 'next/link';
const { data: session, isPending: sessionPending } = useSession(); 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 router = useRouter();
const { data: session, isPending: sessionPending } = useSession();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!sessionPending && session?.user) { if (!sessionPending && session?.user) {
@@ -19,91 +26,64 @@ export default function SignIn() {
} }
}, [sessionPending, session, router]); }, [sessionPending, session, router]);
const handleCredentialsLogin = async (e: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError(null);
try { try {
const result = await signIn.email({ const result = await signIn.email({ email, password });
email,
password,
});
if (result.error) { if (result.error) {
setError(result.error.message || 'Invalid credentials'); setError(result.error.message || 'Invalid credentials');
return; } else {
}
router.replace('/'); router.replace('/');
router.refresh(); router.refresh();
} catch (err) { }
setError('Login failed'); } catch {
setError('Sign in failed');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-4"> <AuthShell
<div className="max-w-md w-full bg-slate-800/50 rounded-lg p-8 border border-slate-700"> title="Sign in"
<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"> subtitle="Authenticate with Better Auth session-backed credentials."
Fiscal Clone footer={(
</h1> <>
<p className="text-slate-400 text-center mb-8">Sign in to your account</p> No account yet?{' '}
<Link href="/auth/signup" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
{error && ( Create one
<div className="bg-red-500/20 border border-red-500 text-red-400 px-4 py-3 rounded-lg mb-6"> </Link>
{error} </>
</div>
)} )}
>
<form onSubmit={handleCredentialsLogin} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2"> <label className="mb-1 block text-sm text-[color:var(--terminal-muted)]">Email</label>
Email <Input type="email" required value={email} onChange={(event) => setEmail(event.target.value)} placeholder="you@company.com" />
</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>
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2"> <label className="mb-1 block text-sm text-[color:var(--terminal-muted)]">Password</label>
Password <Input
</label>
<input
type="password" 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 required
minLength={8} minLength={8}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="********"
/> />
</div> </div>
<button {error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
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"> <Button type="submit" className="w-full" disabled={loading || sessionPending}>
Don&apos;t have an account?{' '} {loading ? 'Signing in...' : 'Sign in'}
<a href="/auth/signup" className="text-blue-400 hover:text-blue-300"> </Button>
Sign up </form>
</a> </AuthShell>
</p>
</div>
</div>
); );
} }

View File

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

View File

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

View File

@@ -2,20 +2,124 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { :root {
:root { --bg-0: #05080d;
--background: 222.2 84% 4.9%; --bg-1: #08121a;
--foreground: 210 40% 98%; --bg-2: #0b1f28;
--border: 217.2 32.6% 17.5%; --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 { @media (max-width: 1024px) {
* { .ambient-grid {
border-color: hsl(var(--border)); background-size: 26px 26px;
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
} }
} }

View File

@@ -1,15 +1,26 @@
import './globals.css'; import './globals.css';
import type { Metadata } from 'next';
import { JetBrains_Mono, Space_Grotesk } from 'next/font/google';
export default function RootLayout({ const display = Space_Grotesk({
children, subsets: ['latin'],
}: { variable: '--font-display'
children: React.ReactNode });
}) {
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 ( return (
<html lang="en"> <html lang="en" className={`${display.variable} ${mono.variable}`}>
<body> <body>{children}</body>
{children}
</body>
</html> </html>
) );
} }

View File

@@ -1,110 +1,229 @@
'use client'; 'use client';
import { useSession } from '@/lib/better-auth';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import Link from 'next/link'; 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() { type DashboardState = {
const { data: session, isPending } = useSession(); summary: PortfolioSummary;
const router = useRouter(); filingsCount: number;
const [stats, setStats] = useState({ filings: 0, portfolioValue: 0, watchlist: 0 }); watchlistCount: number;
tasks: Task[];
latestInsight: PortfolioInsight | null;
};
useEffect(() => { const EMPTY_STATE: DashboardState = {
if (!isPending && !session) { summary: {
router.push('/auth/signin'); positions: 0,
return; 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) { export default function CommandCenterPage() {
fetchStats(session.user.id); const { isPending, isAuthenticated, session } = useAuthGuard();
} const [state, setState] = useState<DashboardState>(EMPTY_STATE);
}, [session, isPending, router]); 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 { try {
const [portfolioRes, watchlistRes, filingsRes] = await Promise.all([ const [summaryRes, filingsRes, watchlistRes, tasksRes, insightRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/portfolio/${userId}/summary`), getPortfolioSummary(),
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/watchlist/${userId}`), listFilings({ limit: 200 }),
fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/filings`) listWatchlist(),
listRecentTasks(20),
getLatestPortfolioInsight()
]); ]);
const portfolioData = await portfolioRes.json(); setState({
const watchlistData = await watchlistRes.json(); summary: summaryRes.summary,
const filingsData = await filingsRes.json(); filingsCount: filingsRes.filings.length,
watchlistCount: watchlistRes.items.length,
setStats({ tasks: tasksRes.tasks,
filings: filingsData.length || 0, latestInsight: insightRes.insight
portfolioValue: portfolioData.total_value || 0,
watchlist: watchlistData.length || 0
}); });
} catch (error) { } catch (err) {
console.error('Error fetching stats:', error); 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 ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white"> <AppShell
<nav className="border-b border-slate-700 bg-slate-900/50 backdrop-blur"> title="Command Center"
<div className="container mx-auto px-4 py-4 flex justify-between items-center"> subtitle={`Welcome back${session?.user?.name ? `, ${session.user.name}` : ''}. Review tasks, portfolio health, and AI outputs.`}
<Link href="/" className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent"> actions={headerActions}
Fiscal Clone >
</Link> {activeTaskId && trackedTask ? (
<div className="flex gap-4"> <Panel title="Live Task" subtitle={`Task ${activeTaskId.slice(0, 8)} is active.`}>
<Link href="/filings" className="hover:text-blue-400 transition"> <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">
Filings <p className="text-sm text-[color:var(--terminal-bright)]">{trackedTask.task_type.replace('_', ' ')}</p>
</Link> <StatusPill status={trackedTask.status} />
<Link href="/portfolio" className="hover:text-blue-400 transition">
Portfolio
</Link>
<Link href="/watchlist" className="hover:text-blue-400 transition">
Watchlist
</Link>
</div> </div>
</div> {trackedTask.error ? (
</nav> <p className="mt-3 text-sm text-[#ff9898]">{trackedTask.error}</p>
) : null}
</Panel>
) : null}
<main className="container mx-auto px-4 py-8"> {error ? (
<div className="mb-6"> <Panel>
<h1 className="text-3xl font-bold">Dashboard</h1> <p className="text-sm text-[#ffb5b5]">{error}</p>
<p className="text-slate-400">Welcome back, {session?.user?.name}</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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700"> <Panel title="Recent Tasks" subtitle="Durable jobs from queue processor" className="xl:col-span-1">
<h3 className="text-slate-400 mb-2">Total Filings</h3> {loading ? (
<p className="text-4xl font-bold text-blue-400">{stats.filings}</p> <p className="text-sm text-[color:var(--terminal-muted)]">Loading tasks...</p>
</div> ) : (
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700"> <TaskFeed tasks={state.tasks} />
<h3 className="text-slate-400 mb-2">Portfolio Value</h3> )}
<p className="text-4xl font-bold text-green-400"> </Panel>
${stats.portfolioValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p> <Panel title="AI Brief" subtitle="Latest portfolio insight from OpenClaw/ZeroClaw" className="xl:col-span-2">
</div> {loading ? (
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700"> <p className="text-sm text-[color:var(--terminal-muted)]">Loading intelligence output...</p>
<h3 className="text-slate-400 mb-2">Watchlist</h3> ) : state.latestInsight ? (
<p className="text-4xl font-bold text-purple-400">{stats.watchlist}</p> <>
<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> </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> </div>
<div className="bg-slate-800/50 rounded-lg p-6 border border-slate-700"> <Panel title="Quick Links" subtitle="Feature modules">
<h2 className="text-xl font-semibold mb-4">Quick Actions</h2> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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">
<Link href="/watchlist/add" className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg text-center transition"> <p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Filings</p>
Add to Watchlist <p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Sync SEC filings and trigger AI memo analysis.</p>
</Link> </Link>
<Link href="/portfolio" className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg text-center transition"> <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">
Add to 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>
<Link href="/filings" className="bg-slate-700 hover:bg-slate-600 text-white px-6 py-3 rounded-lg text-center transition"> <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">
Search SEC Filings <p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Watchlist</p>
</Link> <p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Track priority tickers for monitoring and ingestion.</p>
<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> </Link>
</div> </div>
</Panel>
<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>
</main> </Panel>
</div> </AppShell>
); );
} }

View File

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

View File

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

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({ 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; 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 { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...values: ClassValue[]) {
return twMerge(clsx(inputs)) return clsx(values);
} }

View File

@@ -4,7 +4,19 @@ const nextConfig = {
output: 'standalone', output: 'standalone',
env: { env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001' 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", "name": "fiscal-frontend",
"version": "0.1.0", "version": "2.0.0",
"private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -8,21 +9,14 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "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", "better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.574.0", "lucide-react": "^0.574.0",
"next": "16.1.6", "next": "16.1.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.7.0", "recharts": "^3.7.0"
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0", "@types/node": "^25.3.0",