From d9ee4fce7e4e2a266fbf27719c2238c992d24886 Mon Sep 17 00:00:00 2001 From: Alfredo Date: Sat, 25 Apr 2026 14:19:37 -0500 Subject: [PATCH 1/7] Docker files (#81) * build(docker)!: modernize image builds and compose deployment config Pin the Bun toolchain and runtime dependencies, split guardrails setup into a dedicated build stage, and bundle the realtime socket server into a standalone runtime artifact. Collapse the image workflow into a single multi-platform job that publishes GHCR everywhere and Docker Hub on main. Update the compose stacks, env templates, and docs to require explicit secrets, add Redis, and pin Ollama and prod image tags. BREAKING CHANGE: Docker Compose now requires explicit *POSTGRES_* credentials, *NEXT_PUBLIC_APP_URL*, *BETTER_AUTH_SECRET*, *ENCRYPTION_KEY*, *INTERNAL_API_SECRET*, and tagged images such as *IMAGE_TAG* and *OLLAMA_IMAGE_TAG* instead of relying on the old implicit defaults. * chore(docker): trim env template whitespace * fix(docker): align compose defaults and guardrails stage Update the Docker Compose inputs so they match the current runtime contract: - switch the Docker env example to *postgres://* for *DATABASE_URL* - keep *redis* internal to Compose instead of publishing *6379* on the host - default *NEXT_PUBLIC_SOCKET_URL* to *http://realtime:3002* in Ollama and prod - add *API_ENCRYPTION_KEY* to the local Compose environment - run the guardrails setup from *apps/tradinggoose/lib/guardrails* so the virtualenv is created beside the script * fix(docker): use browser-accessible socket URLs in compose Update the Docker Compose docs and env template to use *apps/tradinggoose/.env* with *--env-file*, point local runs at *http://localhost:3002*, require production to set *NEXT_PUBLIC_SOCKET_URL* explicitly, and document *IMAGE_TAG* and *OLLAMA_IMAGE_TAG* in the Docker env file. Tighten the app runtime image by copying only the Bun *lib0* workspace symlink and by copying the guardrails assets from the build-stage paths that *setup.sh* writes. * fix(docker): slim runtime copies and create realtime build dir (#81) Remove the full root *node_modules* copy from the app runner image and keep only the Bun *lib0* workspace target plus the matching workspace symlink path needed by Yjs. Create */tmp/realtime-build* before running *bun build* so the realtime bundle step can write *socket-server.js* in a fresh Alpine layer. * fix(docker-compose): restore GPU device reservation for ollama (#81) Replace *gpus: all* with the Compose device reservation block so the *ollama* GPU profile actually receives NVIDIA devices at runtime. This keeps the file within the Compose spec and preserves GPU-backed Ollama behavior without relying on a Docker CLI-only flag. * chore(docker): clarify env template comments (#81) Move the setup notes for *BETTER_AUTH_SECRET*, *ENCRYPTION_KEY*, *INTERNAL_API_SECRET*, and *OLLAMA_URL* onto separate lines in *apps/tradinggoose/.env.example.docker* for consistency and readability. --- .dockerignore | 50 +++++- .github/CONTRIBUTING.md | 27 ++- .github/workflows/images.yml | 168 ++++-------------- README.md | 27 ++- apps/tradinggoose/.env.example | 12 +- apps/tradinggoose/.env.example.docker | 51 ++++++ .../lib/guardrails/requirements.txt | 5 +- changelog/April-23-2026.md | 61 +++++++ docker-compose.local.yml | 54 +++--- docker-compose.ollama.yml | 86 ++++----- docker-compose.prod.yml | 66 ++++--- docker/app.Dockerfile | 48 ++--- docker/db.Dockerfile | 16 +- docker/realtime.Dockerfile | 24 +-- 14 files changed, 406 insertions(+), 289 deletions(-) create mode 100644 apps/tradinggoose/.env.example.docker diff --git a/.dockerignore b/.dockerignore index c1689c398..798b4fc82 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,51 @@ +# Git / workspace metadata +.git +.github +.devcontainer +.husky +.codex +.vscode + +# Dependency installs and caches +node_modules +**/node_modules +.turbo +**/.turbo +**/.next +**/out +**/build +**/dist +**/standalone +**/coverage + +# Docs and generated content LICENSE NOTICE +README.md .prettierrc .prettierignore -README.md .gitignore -.husky -.github -.devcontainer .env.example -node_modules \ No newline at end of file +/apps/docs/.source +/apps/docs/.contentlayer +/apps/docs/.content-collections +/apps/docs/.next +/apps/docs/out +/apps/docs/build +/apps/docs/coverage + +# Environment and local secrets +.env +*.env +.env.local +.env.development +.env.test +.env.production + +# Miscellaneous +*.log +*.map +.DS_Store +*.pem +**/postgres_data/ +CURSOR_MEMORY diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fdc7336a3..4521cd1c8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -154,10 +154,21 @@ After running this command, open [http://localhost:3000/](http://localhost:3000/ git clone https://github.com//TradingGoose-Studio.git cd TradingGoose-Studio -# Start TradingGoose -docker compose -f docker-compose.prod.yml up -d +# Copy the Docker Compose env template and set the required secrets/tags +cp apps/tradinggoose/.env.example.docker apps/tradinggoose/.env + +docker compose --env-file ./apps/tradinggoose/.env -f docker-compose.prod.yml up -d ``` +Your Docker `.env` must include `POSTGRES_*`, `NEXT_PUBLIC_APP_URL`, +`NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, +`API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The `ENCRYPTION_KEY` value +must be available to both the app and realtime containers. Use +`http://localhost:3002` for `NEXT_PUBLIC_SOCKET_URL` in local Compose runs, and +override it with a browser-reachable public URL for production. The prod and +Ollama compose files also require `IMAGE_TAG` and `OLLAMA_IMAGE_TAG` +respectively. + Access the application at [http://localhost:3000/](http://localhost:3000/) #### Using Local Models @@ -178,14 +189,13 @@ ollama pull gemma3:4b ```bash # With NVIDIA GPU support -docker compose --profile local-gpu -f docker-compose.ollama.yml up -d +docker compose --env-file ./apps/tradinggoose/.env --profile gpu --profile setup -f docker-compose.ollama.yml up -d # Without GPU (CPU only) -docker compose --profile local-cpu -f docker-compose.ollama.yml up -d +docker compose --env-file ./apps/tradinggoose/.env --profile cpu --profile setup -f docker-compose.ollama.yml up -d -# If hosting on a server, update the environment variables in the docker-compose.prod.yml file -# to include the server's public IP then start again (OLLAMA_URL to i.e. http://1.1.1.1:11434) -docker compose -f docker-compose.prod.yml up -d +# If hosting on a server, point OLLAMA_URL in .env to the remote endpoint +# before starting docker compose -f docker-compose.prod.yml up -d ``` ### Option 3: Using VS Code / Cursor Dev Containers @@ -238,7 +248,8 @@ If you prefer not to use Docker or Dev Containers: cd apps/tradinggoose ``` - Copy `.env.example` to `.env` - - Configure required variables (DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL) + - Configure required variables (DATABASE_URL, NEXT_PUBLIC_APP_URL, BETTER_AUTH_SECRET, ENCRYPTION_KEY, INTERNAL_API_SECRET) + - Add `API_ENCRYPTION_KEY` if you want encrypted API-key storage in local development 3. **Set Up Database:** diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index e1d380459..1ac3a7b73 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -5,178 +5,82 @@ on: branches: [main] workflow_dispatch: +concurrency: + group: build-and-push-images + cancel-in-progress: false + permissions: contents: read packages: write - id-token: write jobs: - build-amd64: - name: Build AMD64 + build-images: + name: Build Images runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: fail-fast: false matrix: include: - dockerfile: ./docker/app.Dockerfile - ghcr_image: ghcr.io/tradinggoose/tradinggoose - ecr_repo_secret: ECR_APP + repo: tradinggoose - dockerfile: ./docker/db.Dockerfile - ghcr_image: ghcr.io/tradinggoose/migrations - ecr_repo_secret: ECR_MIGRATIONS + repo: migrations - dockerfile: ./docker/realtime.Dockerfile - ghcr_image: ghcr.io/tradinggoose/realtime - ecr_repo_secret: ECR_REALTIME - outputs: - registry: ${{ steps.login-ecr.outputs.registry }} + repo: realtime steps: - name: Checkout code uses: actions/checkout@v4 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 with: - role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} - aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + platforms: arm64 - name: Login to GHCR - if: github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to Docker Hub + if: github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx uses: useblacksmith/setup-docker-builder@v1 - name: Generate tags id: meta run: | - ECR_REGISTRY="${{ steps.login-ecr.outputs.registry }}" - ECR_REPO="${{ secrets[matrix.ecr_repo_secret] }}" - GHCR_IMAGE="${{ matrix.ghcr_image }}" - - # ECR tags (always build for ECR) - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - ECR_TAG="latest" - else - ECR_TAG="staging" + set -euo pipefail + + repo="${{ matrix.repo }}" + ghcr_image="ghcr.io/tradinggoose/${repo}" + sha_tag="${{ github.sha }}" + tags=("${ghcr_image}:${sha_tag}") + + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + dockerhub_image="docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${repo}" + tags+=("${ghcr_image}:latest") + tags+=("${dockerhub_image}:${sha_tag}") + tags+=("${dockerhub_image}:latest") fi - ECR_IMAGE="${ECR_REGISTRY}/${ECR_REPO}:${ECR_TAG}" - # Build tags list - TAGS="${ECR_IMAGE}" - - # Add GHCR tags only for main branch - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - GHCR_AMD64="${GHCR_IMAGE}:latest-amd64" - GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64" - TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA" - fi - - echo "tags=${TAGS}" >> $GITHUB_OUTPUT + tags_csv=$(IFS=,; printf '%s' "${tags[*]}") + echo "tags=${tags_csv}" >> "$GITHUB_OUTPUT" - name: Build and push images uses: useblacksmith/build-push-action@v2 with: context: . file: ${{ matrix.dockerfile }} - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - provenance: false - sbom: false - - build-ghcr-arm64: - name: Build ARM64 (GHCR Only) - runs-on: linux-arm64-8-core - if: github.ref == 'refs/heads/main' - strategy: - fail-fast: false - matrix: - include: - - dockerfile: ./docker/app.Dockerfile - image: ghcr.io/tradinggoose/tradinggoose - - dockerfile: ./docker/db.Dockerfile - image: ghcr.io/tradinggoose/migrations - - dockerfile: ./docker/realtime.Dockerfile - image: ghcr.io/tradinggoose/realtime - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@v1 - - - name: Generate ARM64 tags - id: meta - run: | - IMAGE="${{ matrix.image }}" - echo "tags=${IMAGE}:latest-arm64,${IMAGE}:${{ github.sha }}-arm64" >> $GITHUB_OUTPUT - - - name: Build and push ARM64 to GHCR - uses: useblacksmith/build-push-action@v2 - with: - context: . - file: ${{ matrix.dockerfile }} - platforms: linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - provenance: false - sbom: false - - create-ghcr-manifests: - name: Create GHCR Manifests - runs-on: blacksmith-4vcpu-ubuntu-2404 - needs: [build-amd64, build-ghcr-arm64] - if: github.ref == 'refs/heads/main' - strategy: - matrix: - include: - - image: ghcr.io/tradinggoose/tradinggoose - - image: ghcr.io/tradinggoose/migrations - - image: ghcr.io/tradinggoose/realtime - - steps: - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create and push manifests - run: | - IMAGE_BASE="${{ matrix.image }}" - - # Create latest manifest - docker manifest create "${IMAGE_BASE}:latest" \ - "${IMAGE_BASE}:latest-amd64" \ - "${IMAGE_BASE}:latest-arm64" - docker manifest push "${IMAGE_BASE}:latest" - - # Create SHA manifest - docker manifest create "${IMAGE_BASE}:${{ github.sha }}" \ - "${IMAGE_BASE}:${{ github.sha }}-amd64" \ - "${IMAGE_BASE}:${{ github.sha }}-arm64" - docker manifest push "${IMAGE_BASE}:${{ github.sha }}" \ No newline at end of file + provenance: true + sbom: true diff --git a/README.md b/README.md index 39e5afffa..7739e90d9 100644 --- a/README.md +++ b/README.md @@ -64,14 +64,18 @@ It is built for analytics, research, charting, monitoring, and workflow automati bun install ``` -#### 2. Start PostgreSQL database +#### 2. Start PostgreSQL database and Redis ``` docker run --name tradinggoose-db ` - -e POSTGRES_PASSWORD=postgres ` + -e POSTGRES_USER=tradinggoose ` + -e POSTGRES_PASSWORD= ` -e POSTGRES_DB=tradinggoose ` -p 5432:5432 ` -d pgvector/pgvector:pg17 + +docker run -d --name tradinggoose-redis -p 6379:6379 redis ``` + #### 3. Setup environment variables ``` cd apps/tradinggoose && cp .env.example .env @@ -90,6 +94,25 @@ cd ../.. bun run dev:full ``` +## Docker Compose + +If you use Docker Compose, copy `apps/tradinggoose/.env.example.docker` to +`apps/tradinggoose/.env` and set the required secrets before running the +compose manifests. The `.env` must include `POSTGRES_*`, +`NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, +`ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The +`ENCRYPTION_KEY` value is shared by both the app and realtime containers, and +`API_ENCRYPTION_KEY` enables encrypted API-key storage in the app container. +`NEXT_PUBLIC_SOCKET_URL` should point at `http://localhost:3002` for local +Compose runs; production deployments must override it with a browser-reachable +public URL. The prod and Ollama compose files also require `IMAGE_TAG` and +`OLLAMA_IMAGE_TAG` respectively. + +``` +docker compose --env-file ./apps/tradinggoose/.env -f docker-compose.local.yml up +``` + + ## Contributing Pull requests are welcome. diff --git a/apps/tradinggoose/.env.example b/apps/tradinggoose/.env.example index 58959e72f..6e0e3fa37 100644 --- a/apps/tradinggoose/.env.example +++ b/apps/tradinggoose/.env.example @@ -26,7 +26,7 @@ # Required: PostgreSQL connection string used by the Next app, socket server, # and local Drizzle commands. -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/tradinggoose" +DATABASE_URL="postgresql://tradinggoose:replace-with-password@localhost:5432/tradinggoose" # Required: public browser URL for the Studio app. # This should match the URL you actually open in your browser. @@ -37,20 +37,20 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000" BETTER_AUTH_URL="http://localhost:3000" # Required: Better Auth signing secret. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. BETTER_AUTH_SECRET="replace-with-64-hex-characters" # Required: default secret encryption key for stored secrets. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. ENCRYPTION_KEY="replace-with-64-hex-characters" # Recommended: dedicated encryption key for stored API credentials. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. API_ENCRYPTION_KEY="replace-with-64-hex-characters" # Required: internal server-to-server auth secret used by app routes, sockets, # cron endpoints, and other internal calls. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. INTERNAL_API_SECRET="replace-with-64-hex-characters" # Recommended: Redis for cache, locks, idempotency, and rate limiting. @@ -130,7 +130,7 @@ NEXT_PUBLIC_SSO_ENABLED="false" # TRIGGER_SECRET_KEY="" # Optional: secret expected by cron/internal scheduled requests. -# Generate with: openssl rand -hex 32 +# Generate a secure 64-character hex secret. # CRON_SECRET="replace-with-64-hex-characters" ############################################################################### diff --git a/apps/tradinggoose/.env.example.docker b/apps/tradinggoose/.env.example.docker new file mode 100644 index 000000000..21191cd1e --- /dev/null +++ b/apps/tradinggoose/.env.example.docker @@ -0,0 +1,51 @@ +# Database (Required) +DATABASE_URL="postgres://postgres:postgres@db:5432/tradinggoose" + +# PostgreSQL Port (Optional) - defaults to 5432 if not specified +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=tradinggoose +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 + +# Authentication (Required) +# Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation +BETTER_AUTH_SECRET=generate-the-secret +BETTER_AUTH_URL=http://localhost:3000 + +# NextJS (Required) +NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Browser-accessible socket URL for local Compose runs; override it for production. +NEXT_PUBLIC_SOCKET_URL=http://localhost:3002 + +# Docker image tags (Required for compose manifests that pull published images) +IMAGE_TAG=latest +OLLAMA_IMAGE_TAG=latest + +# Security (Required) +# Use `openssl rand -hex 32` to generate, used to encrypt environment variables +ENCRYPTION_KEY=generate-the-key +# Use `openssl rand -hex 32` to generate, used to encrypt internal api routes +INTERNAL_API_SECRET=generate-the-secret + +# Email Provider (Optional) +# RESEND_API_KEY= +# Uncomment and add your key from https://resend.com to send actual emails +# If left commented out, emails will be logged to console instead + +# Local AI Models (Optional) +# URL for local Ollama server - uncomment if using local models +# OLLAMA_URL=http://localhost:11434 + +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= + +# ALPACA_CLIENT_ID= +# ALPACA_CLIENT_SECRET= + +# MARKET_API_URL=https://market.tradinggoose.ai +# MARKET_API_KEY= + +# COPILOT_API_KEY= + +REDIS_URL=redis://redis:6379 diff --git a/apps/tradinggoose/lib/guardrails/requirements.txt b/apps/tradinggoose/lib/guardrails/requirements.txt index 135efae05..8a8724c9b 100644 --- a/apps/tradinggoose/lib/guardrails/requirements.txt +++ b/apps/tradinggoose/lib/guardrails/requirements.txt @@ -1,4 +1,3 @@ # Microsoft Presidio for PII detection -presidio-analyzer>=2.2.0 -presidio-anonymizer>=2.2.0 - +presidio-analyzer==2.2.362 +presidio-anonymizer==2.2.362 diff --git a/changelog/April-23-2026.md b/changelog/April-23-2026.md index dbc0256e8..43d85e8c5 100644 --- a/changelog/April-23-2026.md +++ b/changelog/April-23-2026.md @@ -1,5 +1,66 @@ # April-23-2026 +## docker_files @ f2bc8336 vs origin/staging + +### Summary +- Standardizes the Docker and compose flow around pinned Bun 1.3.11 images, a dedicated guardrails build stage, and a bundled realtime socket-server artifact so the app and realtime containers stay smaller and more predictable. +- Tightens deployment configuration by making Redis, PostgreSQL credentials, auth secrets, encryption keys, and image tags explicit in local, Ollama, and production compose files instead of relying on implicit defaults or generated values. +- Simplifies image publishing to one multi-platform workflow that pushes GHCR everywhere and Docker Hub on `main`, then updates the docs and env templates to match the new contract. + +### Branch Scope +- Compared `1309ad1279fb0af551a7aad47764c43a5fd1b3d8..f2bc8336` against `origin/staging`. +- The working tree was clean during this review, so only committed branch changes are included here. +- Main areas touched: `.dockerignore`, `.github/CONTRIBUTING.md`, `.github/workflows/images.yml`, `README.md`, `apps/tradinggoose/.env.example`, `apps/tradinggoose/.env.example.docker`, `apps/tradinggoose/lib/guardrails/requirements.txt`, `docker-compose.local.yml`, `docker-compose.ollama.yml`, `docker-compose.prod.yml`, `docker/app.Dockerfile`, `docker/db.Dockerfile`, and `docker/realtime.Dockerfile`. + +### Key Changes +- `docker/app.Dockerfile` now builds on `oven/bun:1.3.11-alpine`, pins `turbo@2.5.8` and `sharp@0.34.3`, splits guardrails installation into its own `guardrails` stage, and copies the runtime guardrails virtualenv into the final image instead of rebuilding it in place. +- The guardrails runtime copy paths now match the build stage layout: `setup.sh` installs the virtualenv under `/app/lib/guardrails/venv`, so the final image copies `venv` and `validate_pii.py` from `/app/lib/guardrails` instead of a temporary build directory. +- The runner stage now copies only the Bun `lib0` workspace symlink from `apps/tradinggoose/node_modules` instead of the whole app dependency tree. The standalone Next output already carries the other workspace dependencies, and the narrower copy keeps Yjs resolution intact without clobbering `monaco-editor` in the runtime image. +- `docker/realtime.Dockerfile` now bundles `apps/tradinggoose/socket-server/index.ts` into `/tmp/realtime-build/socket-server.js` and ships only that artifact in the runtime image, rather than copying the full app tree, package tree, and root `package.json` into the container. +- `docker/db.Dockerfile` narrows the migration image to the files `packages/db` actually needs at runtime: `package.json`, `drizzle.config.ts`, `schema.ts`, `consts.ts`, `schema/`, and `migrations/`. +- `.github/workflows/images.yml` collapses the previous AMD64/ARM64 split plus ECR workflow into one matrix job that builds `docker/app.Dockerfile`, `docker/db.Dockerfile`, and `docker/realtime.Dockerfile` for `linux/amd64,linux/arm64`, pushes SHA tags everywhere, and publishes `latest` only on `main` for GHCR and Docker Hub. +- `docker-compose.local.yml`, `docker-compose.ollama.yml`, and `docker-compose.prod.yml` now require explicit `POSTGRES_USER`, `POSTGRES_PASSWORD`, `NEXT_PUBLIC_APP_URL`, `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `INTERNAL_API_SECRET`, and image tags where applicable, add a Redis service dependency, and wire `REDIS_URL=redis://redis:6379` into the app and realtime containers. +- `docker-compose.local.yml` and `docker-compose.ollama.yml` now default `NEXT_PUBLIC_SOCKET_URL` to `http://localhost:3002`, while `docker-compose.prod.yml` requires a browser-reachable explicit value instead of hardcoding the internal Docker service name. +- `apps/tradinggoose/.env.example.docker` now includes `IMAGE_TAG` and `OLLAMA_IMAGE_TAG` so the compose manifests document the published image contract alongside the app secrets. +- `docker-compose.ollama.yml` also switches Ollama to `OLLAMA_IMAGE_TAG` instead of `latest`, keeps the GPU and CPU services separate, and makes the setup helper reference the actual service name when explaining how to pull extra models. +- `.dockerignore` now excludes repo metadata, build output, cache directories, environment files, logs, and local data volumes more aggressively so Docker builds stay focused on source inputs. +- `apps/tradinggoose/.env.example` and `apps/tradinggoose/.env.example.docker` now document the explicit local boot variables, including the Redis URL, socket URL, and compose-specific database/bootstrap defaults. +- `.github/CONTRIBUTING.md` and `README.md` now explain the new compose bootstrap flow and the need to copy the appropriate env template before running Docker-based setups. + +### Design Decisions +- The branch makes the runtime contract explicit instead of hiding it behind generated secrets or image defaults. That keeps local Docker, production compose, and CI image publishing aligned on the same required inputs. +- Redis is treated as a first-class dependency for containerized runs rather than an optional external service, which is why every compose stack now depends on the local Redis container and exports the same `REDIS_URL`. +- The realtime container now owns a bundled socket-server artifact only. That reduces runtime image surface area and keeps the build/runtime split clear: bundle in the Dockerfile, execute the compiled artifact in the final image. +- Guardrails setup is isolated in a dedicated build stage so Python package installation does not leak into the app runtime image beyond the prepared virtualenv. +- The image workflow now publishes multi-platform images from one job instead of maintaining separate build, arm64, and manifest-push jobs. That keeps the publishing path simpler and avoids the previous AWS/ECR dependency chain. + +### Shared Contracts and Helpers to Reuse +- Reuse `apps/tradinggoose/.env.example` for app-only local development and `apps/tradinggoose/.env.example.docker` for Docker Compose setups. Those files now define the canonical required env surface for each path. +- Reuse the explicit `:?` interpolation pattern in `docker-compose.local.yml`, `docker-compose.ollama.yml`, and `docker-compose.prod.yml` when adding new compose services that should fail fast on missing secrets or tags. +- Reuse `docker/app.Dockerfile`, `docker/realtime.Dockerfile`, and `docker/db.Dockerfile` as the canonical container build patterns for app, socket server, and migrations work instead of copying full source trees into new images. +- Reuse the single-job publishing model in `.github/workflows/images.yml` for future image variants so tag generation and platform coverage stay centralized. + +### Removed or Replaced Items +- The old implicit env defaults for PostgreSQL credentials, auth secrets, encryption keys, and image tags were replaced. Future branches should not reintroduce silent fallbacks such as `postgres`, `latest`, or generated compose-time secrets for these paths. +- The previous separate AMD64/ARM64 build jobs, manifest assembly job, and AWS/ECR login path in `.github/workflows/images.yml` were replaced by the single GHCR/Docker Hub workflow. +- The realtime image no longer copies the full app and package trees into the runtime layer. The replacement is the bundled `socket-server.js` artifact produced during the Docker build. +- The db image no longer copies the whole `packages/db` directory. The replacement is the explicit migration input set copied in `docker/db.Dockerfile`. + +### Future Branch Guardrails +- Do not add new compose services that depend on hidden defaults when the rest of the stack now expects explicit `:?`-guarded env vars. +- Do not reintroduce `latest`-only image references for production or compose-based local boot; use tagged images and make the tag requirement visible in the env file. +- Do not point `NEXT_PUBLIC_SOCKET_URL` at Docker-internal hostnames such as `realtime`; the browser reads this env directly, so local Compose should use `http://localhost:3002` and production should override it with a public URL. +- Do not expand the realtime runtime image back into a full app image unless the socket-server bundling strategy changes with it. +- Do not restore the full `apps/tradinggoose/node_modules` copy in `docker/app.Dockerfile`; the runner image only needs the explicit `lib0` symlink to keep Yjs working. +- Do not move guardrails installation back into the main app runtime layer; keep the isolated build stage and copy only its prepared virtualenv forward. +- Do not split the image publishing path back into separate platform-specific jobs unless the registry or platform requirements genuinely change. + +### Validation Notes +- Reviewed `git status --short --branch`, `git log --oneline origin/staging..HEAD`, `git diff --stat origin/staging...HEAD`, and `git diff --name-status --find-renames origin/staging...HEAD`. +- Verified patch hygiene with `git diff --check origin/staging...HEAD`. +- Validated all three compose files with `docker compose -f docker-compose.local.yml config`, `docker compose -f docker-compose.ollama.yml config`, and `docker compose -f docker-compose.prod.yml config` using explicit placeholder env values. +- No application tests were added or updated in this branch; the change set is Docker, compose, and documentation focused. + ## fix/copilot-billing @ 6a60cc11 vs origin/staging ### Summary diff --git a/docker-compose.local.yml b/docker-compose.local.yml index f93d44d4f..81cf11965 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,26 +1,34 @@ services: + redis: + image: redis:7.2.1-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 tradinggoose: build: context: . dockerfile: docker/app.Dockerfile ports: - '3000:3000' - deploy: - resources: - limits: - memory: 8G environment: - - NODE_ENV=development - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - COPILOT_API_URL=${COPILOT_API_URL} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - REDIS_URL=redis://redis:6379 + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: + redis: + condition: service_healthy db: condition: service_healthy migrations: @@ -39,20 +47,22 @@ services: context: . dockerfile: docker/realtime.Dockerfile environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - REDIS_URL=redis://redis:6379 depends_on: + redis: + condition: service_healthy db: condition: service_healthy restart: unless-stopped ports: - '3002:3002' - deploy: - resources: - limits: - memory: 8G healthcheck: test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health'] interval: 90s @@ -66,7 +76,7 @@ services: dockerfile: docker/db.Dockerfile working_dir: /app/packages/db environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} depends_on: db: condition: service_healthy @@ -79,13 +89,13 @@ services: ports: - '${POSTGRES_PORT:-5432}:5432' environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_USER=${POSTGRES_USER:?set POSTGRES_USER in .env} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} - POSTGRES_DB=${POSTGRES_DB:-tradinggoose} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:?set POSTGRES_USER in .env}'] interval: 5s timeout: 5s retries: 5 diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index ae265073c..ddc91e53d 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -1,6 +1,12 @@ name: tradinggoose-with-ollama - services: + redis: + image: redis:7.2.1-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 # Main TradingGoose Studio Application tradinggoose: build: @@ -8,21 +14,23 @@ services: dockerfile: docker/app.Dockerfile ports: - '3000:3000' - deploy: - resources: - limits: - memory: 8G environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-tradinggoose_auth_secret_$(openssl rand -hex 16)} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-$(openssl rand -hex 32)} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - COPILOT_API_URL=${COPILOT_API_URL} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - REDIS_URL=redis://redis:6379 + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=http://ollama:11434 - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: + redis: + condition: service_healthy db: condition: service_healthy migrations: @@ -43,20 +51,22 @@ services: context: . dockerfile: docker/realtime.Dockerfile environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-tradinggoose_auth_secret_$(openssl rand -hex 16)} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - REDIS_URL=redis://redis:6379 depends_on: + redis: + condition: service_healthy db: condition: service_healthy restart: unless-stopped ports: - '3002:3002' - deploy: - resources: - limits: - memory: 8G healthcheck: test: ['CMD', 'wget', '--spider', '--quiet', 'http://127.0.0.1:3002/health'] interval: 90s @@ -70,7 +80,7 @@ services: context: . dockerfile: docker/db.Dockerfile environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} depends_on: db: condition: service_healthy @@ -81,16 +91,14 @@ services: db: image: pgvector/pgvector:pg17 restart: always - ports: - - '${POSTGRES_PORT:-5432}:5432' environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_USER=${POSTGRES_USER:?set POSTGRES_USER in .env} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} - POSTGRES_DB=${POSTGRES_DB:-tradinggoose} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:?set POSTGRES_USER in .env}'] interval: 5s timeout: 5s retries: 5 @@ -99,19 +107,9 @@ services: ollama: profiles: - gpu - image: ollama/ollama:latest - pull_policy: always + image: ollama/ollama:${OLLAMA_IMAGE_TAG:?set OLLAMA_IMAGE_TAG in .env} volumes: - ollama_data:/root/.ollama - ports: - - '11434:11434' - environment: - - NVIDIA_DRIVER_CAPABILITIES=all - - OLLAMA_LOAD_TIMEOUT=-1 - - OLLAMA_KEEP_ALIVE=-1 - - OLLAMA_DEBUG=1 - - OLLAMA_HOST=0.0.0.0:11434 - command: 'serve' deploy: resources: reservations: @@ -119,6 +117,12 @@ services: - driver: nvidia count: all capabilities: [gpu] + environment: + - NVIDIA_DRIVER_CAPABILITIES=all + - OLLAMA_LOAD_TIMEOUT=-1 + - OLLAMA_KEEP_ALIVE=-1 + - OLLAMA_HOST=0.0.0.0:11434 + command: 'serve' healthcheck: test: ['CMD', 'ollama', 'list'] interval: 10s @@ -127,20 +131,16 @@ services: start_period: 30s restart: unless-stopped - # Ollama CPU-only version (use with --profile cpu profile) + # Ollama CPU-only version (use with --profile cpu) ollama-cpu: profiles: - cpu - image: ollama/ollama:latest - pull_policy: always + image: ollama/ollama:${OLLAMA_IMAGE_TAG:?set OLLAMA_IMAGE_TAG in .env} volumes: - ollama_data:/root/.ollama - ports: - - '11434:11434' environment: - OLLAMA_LOAD_TIMEOUT=-1 - OLLAMA_KEEP_ALIVE=-1 - - OLLAMA_DEBUG=1 - OLLAMA_HOST=0.0.0.0:11434 command: 'serve' healthcheck: @@ -157,7 +157,7 @@ services: # Helper container to pull models automatically model-setup: - image: ollama/ollama:latest + image: ollama/ollama:${OLLAMA_IMAGE_TAG:?set OLLAMA_IMAGE_TAG in .env} profiles: - setup volumes: @@ -172,7 +172,7 @@ services: echo 'Pulling gemma3:4b model (recommended starter model)...' && ollama pull gemma3:4b && echo 'Model setup complete! You can now use gemma3:4b in TradingGoose.' && - echo 'To add more models, run: docker compose -f docker-compose.ollama.yml exec ollama ollama pull ' + echo 'To add more models, run: docker compose -f docker-compose.ollama.yml exec ollama pull ' " restart: 'no' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7668bc1dd..fc0c62a5e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,25 +1,33 @@ services: + redis: + image: redis:7.2.1-alpine + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 tradinggoose: - image: ghcr.io/tradinggoose/tradinggoose:latest + image: ghcr.io/tradinggoose/tradinggoose:${IMAGE_TAG:?set IMAGE_TAG in .env} restart: unless-stopped ports: - '3000:3000' - deploy: - resources: - limits: - memory: 8G environment: - NODE_ENV=production - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} - - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - COPILOT_API_URL=${COPILOT_API_URL} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - REDIS_URL=redis://redis:6379 + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:?set NEXT_PUBLIC_SOCKET_URL in .env} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: + redis: + condition: service_healthy db: condition: service_healthy migrations: @@ -34,20 +42,22 @@ services: start_period: 10s realtime: - image: ghcr.io/tradinggoose/realtime:latest + image: ghcr.io/tradinggoose/realtime:${IMAGE_TAG:?set IMAGE_TAG in .env} restart: unless-stopped ports: - '3002:3002' - deploy: - resources: - limits: - memory: 4G environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} - - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} + - NODE_ENV=production + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - REDIS_URL=redis://redis:6379 depends_on: + redis: + condition: service_healthy db: condition: service_healthy healthcheck: @@ -58,10 +68,10 @@ services: start_period: 10s migrations: - image: ghcr.io/tradinggoose/migrations:latest + image: ghcr.io/tradinggoose/migrations:${IMAGE_TAG:?set IMAGE_TAG in .env} working_dir: /app/packages/db environment: - - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-tradinggoose} + - DATABASE_URL=postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@db:5432/${POSTGRES_DB:-tradinggoose} depends_on: db: condition: service_healthy @@ -71,16 +81,14 @@ services: db: image: pgvector/pgvector:pg17 restart: unless-stopped - ports: - - '${POSTGRES_PORT:-5432}:5432' environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_USER=${POSTGRES_USER:?set POSTGRES_USER in .env} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} - POSTGRES_DB=${POSTGRES_DB:-tradinggoose} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:?set POSTGRES_USER in .env}'] interval: 5s timeout: 5s retries: 5 diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 004c55eca..1cf43edfb 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.2.22-alpine AS base +FROM oven/bun:1.3.11-alpine AS base # ======================================== # Dependencies Stage: Install Dependencies @@ -10,9 +10,6 @@ FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app -# Install turbo globally -RUN bun install -g turbo - COPY package.json bun.lock ./ RUN mkdir -p apps COPY apps/tradinggoose/package.json ./apps/tradinggoose/package.json @@ -26,7 +23,7 @@ FROM base AS builder WORKDIR /app # Install turbo globally in builder stage -RUN bun install -g turbo +RUN bun install -g turbo@2.5.8 COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -36,7 +33,7 @@ RUN bun install --omit dev --ignore-scripts # Required for standalone nextjs build WORKDIR /app/apps/tradinggoose -RUN bun install sharp +RUN bun install sharp@0.34.3 ENV NEXT_TELEMETRY_DISABLED=1 \ VERCEL_TELEMETRY_DISABLED=1 \ @@ -56,6 +53,19 @@ ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} RUN bun run build +# ======================================== +# Guardrails Stage: Build Presidio runtime +# ======================================== +FROM base AS guardrails +RUN apk add --no-cache python3 py3-pip bash +WORKDIR /app/lib/guardrails + +COPY apps/tradinggoose/lib/guardrails/setup.sh ./setup.sh +COPY apps/tradinggoose/lib/guardrails/requirements.txt ./requirements.txt +COPY apps/tradinggoose/lib/guardrails/validate_pii.py ./validate_pii.py + +RUN chmod +x ./setup.sh && ./setup.sh + # ======================================== # Runner Stage: Run the actual app # ======================================== @@ -63,8 +73,8 @@ RUN bun run build FROM base AS runner WORKDIR /app -# Install Python and dependencies for guardrails PII detection -RUN apk add --no-cache python3 py3-pip bash +# Install Python runtime for guardrails PII detection +RUN apk add --no-cache python3 ENV NODE_ENV=production @@ -75,21 +85,17 @@ RUN addgroup -g 1001 -S nodejs && \ COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/public ./apps/tradinggoose/public COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/.next/static ./apps/tradinggoose/.next/static +# Preserve Bun's workspace target for lib0 so yjs can resolve it at runtime. +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.bun/lib0@0.2.102/node_modules/lib0 ./node_modules/.bun/lib0@0.2.102/node_modules/lib0 +COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/node_modules/lib0 ./apps/tradinggoose/node_modules/lib0 -# Guardrails setup (files need to be owned by nextjs for runtime) -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/lib/guardrails/setup.sh ./apps/tradinggoose/lib/guardrails/setup.sh -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/lib/guardrails/requirements.txt ./apps/tradinggoose/lib/guardrails/requirements.txt -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose/lib/guardrails/validate_pii.py ./apps/tradinggoose/lib/guardrails/validate_pii.py - -# Run guardrails setup as root, then fix ownership of generated venv files -RUN chmod +x ./apps/tradinggoose/lib/guardrails/setup.sh && \ - cd ./apps/tradinggoose/lib/guardrails && \ - ./setup.sh && \ - chown -R nextjs:nodejs /app/apps/tradinggoose/lib/guardrails +# Guardrails runtime assets +COPY --from=guardrails --chown=nextjs:nodejs /app/lib/guardrails/venv ./lib/guardrails/venv +COPY --from=guardrails --chown=nextjs:nodejs /app/lib/guardrails/validate_pii.py ./lib/guardrails/validate_pii.py -# Create .next/cache directory with correct ownership +# Create the writable .next/cache directory for the non-root runtime user RUN mkdir -p apps/tradinggoose/.next/cache && \ - chown -R nextjs:nodejs /app + chown nextjs:nodejs apps/tradinggoose/.next/cache # Switch to non-root user USER nextjs @@ -98,4 +104,4 @@ EXPOSE 3000 ENV PORT=3000 \ HOSTNAME="0.0.0.0" -CMD ["bun", "apps/tradinggoose/server.js"] \ No newline at end of file +CMD ["bun", "apps/tradinggoose/server.js"] diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile index 32c8f3add..2f951ac9f 100644 --- a/docker/db.Dockerfile +++ b/docker/db.Dockerfile @@ -1,11 +1,11 @@ # ======================================== # Dependencies Stage: Install Dependencies # ======================================== -FROM oven/bun:1.2.22-alpine AS deps +FROM oven/bun:1.3.11-alpine AS deps WORKDIR /app # Copy only package files needed for migrations -COPY package.json bun.lock turbo.json ./ +COPY package.json bun.lock ./ COPY packages/db/package.json ./packages/db/package.json # Install dependencies @@ -14,19 +14,23 @@ RUN bun install --ignore-scripts # ======================================== # Runner Stage: Production Environment # ======================================== -FROM oven/bun:1.2.22-alpine AS runner +FROM oven/bun:1.3.11-alpine AS runner WORKDIR /app # Create non-root user and group RUN addgroup -g 1001 -S nodejs && \ adduser -S nextjs -u 1001 -# Copy only the necessary files from deps +# Copy only the migration inputs and runtime files from the db package COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules +COPY --chown=nextjs:nodejs packages/db/package.json ./packages/db/package.json COPY --chown=nextjs:nodejs packages/db/drizzle.config.ts ./packages/db/drizzle.config.ts -COPY --chown=nextjs:nodejs packages/db ./packages/db +COPY --chown=nextjs:nodejs packages/db/schema.ts ./packages/db/schema.ts +COPY --chown=nextjs:nodejs packages/db/consts.ts ./packages/db/consts.ts +COPY --chown=nextjs:nodejs packages/db/schema ./packages/db/schema +COPY --chown=nextjs:nodejs packages/db/migrations ./packages/db/migrations # Switch to non-root user USER nextjs -WORKDIR /app/packages/db \ No newline at end of file +WORKDIR /app/packages/db diff --git a/docker/realtime.Dockerfile b/docker/realtime.Dockerfile index d0db81139..4ff1832d6 100644 --- a/docker/realtime.Dockerfile +++ b/docker/realtime.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.2.22-alpine AS base +FROM oven/bun:1.3.11-alpine AS base # ======================================== # Dependencies Stage: Install Dependencies @@ -10,9 +10,6 @@ FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app -# Install turbo globally -RUN bun install -g turbo - COPY package.json bun.lock ./ RUN mkdir -p apps COPY apps/tradinggoose/package.json ./apps/tradinggoose/package.json @@ -28,11 +25,17 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +RUN bun install --omit dev --ignore-scripts + +WORKDIR /app/apps/tradinggoose +RUN mkdir -p /tmp/realtime-build && \ + bun build --target bun --outfile /tmp/realtime-build/socket-server.js socket-server/index.ts + # ======================================== # Runner Stage: Run the Socket Server # ======================================== FROM base AS runner -WORKDIR /app +WORKDIR /app/apps/tradinggoose ENV NODE_ENV=production @@ -40,11 +43,8 @@ ENV NODE_ENV=production RUN addgroup -g 1001 -S nodejs && \ adduser -S nextjs -u 1001 -# Copy the tradinggoose app and the shared db package needed by socket-server -COPY --from=builder --chown=nextjs:nodejs /app/apps/tradinggoose ./apps/tradinggoose -COPY --from=builder --chown=nextjs:nodejs /app/packages/db ./packages/db -COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules -COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json +# Copy the bundled socket server runtime artifact only +COPY --from=builder --chown=nextjs:nodejs /tmp/realtime-build/socket-server.js ./socket-server.js # Switch to non-root user USER nextjs @@ -55,5 +55,5 @@ ENV PORT=3002 \ SOCKET_PORT=3002 \ HOSTNAME="0.0.0.0" -# Run the socket server directly -CMD ["bun", "apps/tradinggoose/socket-server/index.ts"] \ No newline at end of file +# Run the bundled socket server directly +CMD ["bun", "./socket-server.js"] From 7d4834520a0066a0e923d35bfd90a0614b0393d5 Mon Sep 17 00:00:00 2001 From: Bruzzz Date: Wed, 29 Apr 2026 12:47:43 -0600 Subject: [PATCH 2/7] fix copilot thinking rendering (#82) * fix(copilot): normalize reasoning envelopes * fix(copilot): hide unknown thought duration * fix(copilot): clarify finished thinking label * fix(copilot): render thinking markdown * fix(copilot): preserve reasoning with content blocks * fix(copilot): stabilize reasoning block timestamps --- .../stores/copilot/store-messages.test.ts | 127 +++++++++++++++ .../stores/copilot/store-messages.ts | 150 +++++++++++++++++- .../components/thinking-group.test.tsx | 37 +++++ .../components/thinking-group.tsx | 37 +++-- changelog/April-29-2026.md | 46 ++++++ 5 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 apps/tradinggoose/stores/copilot/store-messages.test.ts create mode 100644 changelog/April-29-2026.md diff --git a/apps/tradinggoose/stores/copilot/store-messages.test.ts b/apps/tradinggoose/stores/copilot/store-messages.test.ts new file mode 100644 index 000000000..cba14a911 --- /dev/null +++ b/apps/tradinggoose/stores/copilot/store-messages.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' +import { normalizeMessagesForUI } from './store-messages' +import type { CopilotMessage } from './types' + +describe('normalizeMessagesForUI', () => { + it('moves reasoning-only JSON prefixes out of assistant text content', () => { + const [message] = normalizeMessagesForUI([ + { + id: 'assistant-1', + role: 'assistant', + content: `${JSON.stringify({ reasoning: 'Internal reasoning.' })}\n\nVisible reply.`, + timestamp: '2026-04-28T00:00:00.000Z', + }, + ]) + + expect(message.content).toBe('Visible reply.') + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Internal reasoning.', + }, + { + type: 'text', + content: 'Visible reply.', + }, + ]) + }) + + it('normalizes persisted assistant text blocks with reasoning JSON prefixes', () => { + const [message] = normalizeMessagesForUI([ + { + id: 'assistant-2', + role: 'assistant', + content: '', + timestamp: '2026-04-28T00:00:00.000Z', + contentBlocks: [ + { + type: 'text', + content: `${JSON.stringify({ reasoning: 'Block reasoning.' })}\n\nBlock reply.`, + timestamp: 1, + itemId: 'text-1', + }, + ], + } satisfies CopilotMessage, + ]) + + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Block reasoning.', + itemId: 'text-1-reasoning', + }, + { + type: 'text', + content: 'Block reply.', + itemId: 'text-1', + }, + ]) + }) + + it('uses explicit reply text from full JSON assistant envelopes', () => { + const [message] = normalizeMessagesForUI([ + { + id: 'assistant-3', + role: 'assistant', + content: JSON.stringify({ + reasoning: 'Envelope reasoning.', + reply: 'Envelope reply.', + }), + timestamp: '2026-04-28T00:00:00.000Z', + }, + ]) + + expect(message.content).toBe('Envelope reply.') + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Envelope reasoning.', + }, + { + type: 'text', + content: 'Envelope reply.', + }, + ]) + }) + + it('preserves content-level reasoning when assistant content blocks already exist', () => { + const input = { + id: 'assistant-4', + role: 'assistant', + content: `${JSON.stringify({ reasoning: 'Content reasoning.' })}\n\nVisible reply.`, + timestamp: '2026-04-28T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool_call', + timestamp: 1, + toolCall: { + id: 'tool-1', + name: 'get_user_workflow', + state: ClientToolCallState.success, + }, + }, + ], + } satisfies CopilotMessage + const [message] = normalizeMessagesForUI([input]) + const [normalizedAgain] = normalizeMessagesForUI([input]) + + expect(message.content).toBe('Visible reply.') + expect(message.contentBlocks).toMatchObject([ + { + type: 'thinking', + content: 'Content reasoning.', + timestamp: Date.parse(input.timestamp), + }, + { + type: 'tool_call', + toolCall: { + id: 'tool-1', + }, + }, + ]) + expect((normalizedAgain.contentBlocks?.[0] as any)?.timestamp).toBe( + (message.contentBlocks?.[0] as any)?.timestamp + ) + }) +}) diff --git a/apps/tradinggoose/stores/copilot/store-messages.ts b/apps/tradinggoose/stores/copilot/store-messages.ts index 470387020..d38a39e45 100644 --- a/apps/tradinggoose/stores/copilot/store-messages.ts +++ b/apps/tradinggoose/stores/copilot/store-messages.ts @@ -15,6 +15,120 @@ import type { MessageFileAttachment, } from '@/stores/copilot/types' +function parseJsonObjectPrefix( + value: string +): { object: Record; rest: string } | null { + let inString = false + let escaped = false + let depth = 0 + const start = value.search(/\S/) + if (start < 0 || value[start] !== '{') return null + + for (let index = start; index < value.length; index++) { + const char = value[index] + + if (inString) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + continue + } + + if (char === '{') { + depth += 1 + continue + } + + if (char !== '}') continue + depth -= 1 + if (depth !== 0) continue + + try { + const parsed = JSON.parse(value.slice(start, index + 1)) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + return { + object: parsed as Record, + rest: value.slice(index + 1), + } + } catch { + return null + } + } + + return null +} + +function normalizeAssistantReasoningContent(content: string): { + content: string + reasoning?: string +} { + const parsed = parseJsonObjectPrefix(content) + if (!parsed || typeof parsed.object.reasoning !== 'string') { + return { content } + } + + const explicitContent = + typeof parsed.object.reply === 'string' + ? parsed.object.reply + : typeof parsed.object.content === 'string' + ? parsed.object.content + : typeof parsed.object.message === 'string' + ? parsed.object.message + : typeof parsed.object.text === 'string' + ? parsed.object.text + : undefined + + return { + content: (explicitContent ?? parsed.rest).trim(), + reasoning: parsed.object.reasoning.trim(), + } +} + +function normalizeAssistantContentBlocks(blocks: any[]): any[] { + return blocks.flatMap((block) => { + if (block?.type !== 'text' || typeof block.content !== 'string') { + return [block] + } + + const normalized = normalizeAssistantReasoningContent(block.content) + if (!normalized.reasoning) { + return [block] + } + + const timestamp = typeof block.timestamp === 'number' ? block.timestamp : Date.now() + return [ + { + type: 'thinking', + content: normalized.reasoning, + timestamp, + ...(typeof block.itemId === 'string' ? { itemId: `${block.itemId}-reasoning` } : {}), + }, + ...(normalized.content + ? [ + { + ...block, + content: normalized.content, + }, + ] + : []), + ] + }) +} + +function getMessageBlockTimestamp(message: CopilotMessage): number { + const timestamp = Date.parse(message.timestamp) + return Number.isFinite(timestamp) ? timestamp : Date.now() +} + export function normalizeMessagesForUI( messages: CopilotMessage[], latestTurnStatus?: string | null, @@ -35,7 +149,9 @@ export function normalizeMessagesForUI( return message } - const blocks: any[] = Array.isArray(message.contentBlocks) + const normalizedContent = normalizeAssistantReasoningContent(message.content || '') + const messageBlockTimestamp = getMessageBlockTimestamp(message) + const hydratedBlocks: any[] = Array.isArray(message.contentBlocks) ? (message.contentBlocks as any[]).map((b: any) => { if (b?.type === 'tool_call' && b.toolCall) { const normalizedToolCall = { @@ -73,6 +189,16 @@ export function normalizeMessagesForUI( return b }) : [] + const blocks = normalizeAssistantContentBlocks(hydratedBlocks) + const reasoningBlock = + normalizedContent.reasoning && !blocks.some((block: any) => block?.type === 'thinking') + ? { + type: 'thinking', + content: normalizedContent.reasoning, + timestamp: messageBlockTimestamp, + } + : null + const finalBlocks = reasoningBlock && blocks.length > 0 ? [reasoningBlock, ...blocks] : blocks const updatedToolCalls = Array.isArray((message as any).toolCalls) ? (message as any).toolCalls.map((tc: any) => { @@ -109,11 +235,25 @@ export function normalizeMessagesForUI( return { ...message, + content: normalizedContent.content, ...(updatedToolCalls && { toolCalls: updatedToolCalls }), - ...(blocks.length > 0 - ? { contentBlocks: blocks } - : message.content?.trim() - ? { contentBlocks: [{ type: 'text', content: message.content, timestamp: Date.now() }] } + ...(finalBlocks.length > 0 + ? { contentBlocks: finalBlocks } + : normalizedContent.reasoning || normalizedContent.content.trim() + ? { + contentBlocks: [ + ...(reasoningBlock ? [reasoningBlock] : []), + ...(normalizedContent.content.trim() + ? [ + { + type: 'text', + content: normalizedContent.content, + timestamp: messageBlockTimestamp, + }, + ] + : []), + ], + } : {}), } }) diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx index 14f931d8d..17ebe2adc 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx @@ -56,4 +56,41 @@ describe('ThinkingGroup', () => { expect(container.textContent).toContain('Thought for 1.3s') expect(container.textContent).not.toContain('Thinking...') }) + + it('does not show a fake zero duration when timing is unavailable', async () => { + const blocks = [ + { + type: 'thinking' as const, + content: 'Historical reasoning.', + timestamp: 1, + itemId: 'thinking-1', + }, + ] + + await act(async () => { + root.render() + }) + + expect(container.textContent).toContain('Finished thinking') + expect(container.textContent).not.toContain('Thought for 0ms') + }) + + it('renders expanded thinking content as markdown', async () => { + const blocks = [ + { + type: 'thinking' as const, + content: 'Inspecting **workflow**.\n\n- Validate edges', + timestamp: 1, + itemId: 'thinking-1', + }, + ] + + await act(async () => { + root.render() + }) + + expect(container.querySelector('strong')?.textContent).toBe('workflow') + expect(container.querySelector('ul')?.textContent).toContain('Validate edges') + expect(container.textContent).not.toContain('**workflow**') + }) }) diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx index 779c4e4a4..e8e50b1bd 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Brain, ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' import type { CopilotMessage } from '@/stores/copilot/types' +import CopilotMarkdownRenderer from './markdown-renderer' type ThinkingContentBlock = Extract< NonNullable[number], @@ -23,16 +24,17 @@ function formatDuration(ms: number) { return `${(ms / 1000).toFixed(1)}s` } -function getThinkingDuration(block: ThinkingContentBlock) { - if (typeof block.duration === 'number') { +function getThinkingDuration(block: ThinkingContentBlock): number | null { + if (typeof block.duration === 'number' && block.duration > 0) { return block.duration } if (typeof block.startTime === 'number') { - return Date.now() - block.startTime + const duration = Date.now() - block.startTime + return duration > 0 ? duration : null } - return 0 + return null } export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProps) { @@ -48,10 +50,13 @@ export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProp [blocks] ) - const totalDuration = useMemo( - () => blocks.reduce((sum, block) => sum + getThinkingDuration(block), 0), - [blocks] - ) + const totalDuration = useMemo(() => { + let total = 0 + for (const block of blocks) { + total += getThinkingDuration(block) ?? 0 + } + return total + }, [blocks]) useEffect(() => { if (!isStreaming) { @@ -65,7 +70,11 @@ export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProp } }, [content, isStreaming]) - const headerLabel = isStreaming ? 'Thinking...' : `Thought for ${formatDuration(totalDuration)}` + const headerLabel = isStreaming + ? 'Thinking...' + : totalDuration > 0 + ? `Thought for ${formatDuration(totalDuration)}` + : 'Finished thinking' return (
@@ -97,12 +106,10 @@ export function ThinkingGroup({ blocks, isStreaming = false }: ThinkingGroupProp {isExpanded && content ? (
-
-            {content}
-            {isStreaming ? (
-              
-            ) : null}
-          
+ + {isStreaming ? ( + + ) : null}
) : null}
diff --git a/changelog/April-29-2026.md b/changelog/April-29-2026.md new file mode 100644 index 000000000..5bfbc6f9c --- /dev/null +++ b/changelog/April-29-2026.md @@ -0,0 +1,46 @@ +# April-29-2026 + +## fix/copilot-render @ 7506fe2 vs origin/staging + +### Summary +- Fixes Copilot assistant rendering so JSON-prefixed reasoning envelopes are rendered as thinking blocks instead of leaking into visible markdown text. +- Keeps thinking block labels and unknown-duration rendering stable in `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx`. +- Preserves content-level reasoning when persisted assistant messages already include other content blocks, such as tool calls. +- Adds a review follow-up so synthesized content-level reasoning blocks use a deterministic timestamp derived from the persisted message timestamp. + +### Branch Scope +- Compared `d9ee4fce7e4e2a266fbf27719c2238c992d24886..fix/copilot-render` against `origin/staging`. +- The staged review follow-up in this branch updates the same Copilot message normalization surface and this changelog file. +- Main areas touched: `apps/tradinggoose/stores/copilot/store-messages.ts`, `apps/tradinggoose/stores/copilot/store-messages.test.ts`, and `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx`. + +### Key Changes +- `apps/tradinggoose/stores/copilot/store-messages.ts` now normalizes assistant JSON reasoning envelopes into `thinking` content blocks while keeping the visible assistant reply separate. +- `apps/tradinggoose/stores/copilot/store-messages.ts` prepends content-level reasoning to existing content blocks when the persisted assistant message already has tool-call blocks. +- `apps/tradinggoose/stores/copilot/store-messages.ts` now derives synthesized content-level block timestamps from `message.timestamp`, keeping repeated `normalizeMessagesForUI()` calls deterministic for the same input message. +- `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx` renders completed thinking with markdown content and avoids showing misleading unknown durations. +- `apps/tradinggoose/stores/copilot/store-messages.test.ts` and `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.test.tsx` cover the reasoning normalization and thinking group rendering paths. + +### Design Decisions +- Reasoning extracted from persisted message content belongs in a `thinking` content block so the visible message body remains user-facing reply text only. +- Synthetic content-level blocks reuse the persisted message timestamp instead of wall-clock time so normalization is idempotent and safe for React rendering. +- The renderer keeps thinking markdown support inside the existing thinking group component rather than introducing a parallel markdown surface. + +### Shared Contracts and Helpers to Reuse +- Reuse `normalizeMessagesForUI()` in `apps/tradinggoose/stores/copilot/store-messages.ts` as the canonical UI normalization path for persisted Copilot messages. +- Reuse the `thinking` content block shape from `apps/tradinggoose/stores/copilot/types.ts` when adding future reasoning display behavior. +- Reuse `ThinkingGroup` in `apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/thinking-group.tsx` for reasoning block rendering instead of rendering reasoning in the assistant body. + +### Removed or Replaced Items +- Raw JSON reasoning envelopes are no longer left in visible assistant message text. +- Fresh `Date.now()` timestamps for content-level synthesized reasoning/text blocks were replaced with timestamps derived from the persisted assistant message. +- No files were deleted in this branch. + +### Future Branch Guardrails +- Do not render persisted reasoning JSON directly in the visible assistant markdown body. +- Do not synthesize content-level Copilot blocks with fresh wall-clock timestamps when a persisted message timestamp is available. +- Do not duplicate thinking markdown rendering outside `ThinkingGroup`. + +### Validation Notes +- Reviewed `git merge-base origin/staging fix/copilot-render`, `git log --oneline origin/staging..fix/copilot-render`, and `git diff --stat origin/staging...fix/copilot-render`. +- Inspected `apps/tradinggoose/stores/copilot/store-messages.ts`, `apps/tradinggoose/stores/copilot/store-messages.test.ts`, and existing changelog entries for repository format. +- Ran the focused Copilot message normalization test file after the review follow-up. From 1641a066ed6d246f3259c57b1e0f474831586b6f Mon Sep 17 00:00:00 2001 From: Bruzzz Date: Sun, 3 May 2026 19:18:59 -0600 Subject: [PATCH 3/7] feat(widgets): add portfolio trading widgets (#83) * feat(widgets): add portfolio snapshot widget * feat(widgets): add quick order widget Co-authored-by: Codex * feat(widgets): add heatmap market APIs * feat(widgets): add heatmap widget * fix(widgets): keep quote snapshot contract client safe * fix(widgets): simplify trading account selection * refactor(widgets): unify provider header controls * refactor(widgets): consolidate provider APIs and portfolio UI * refactor(widgets): stream market quote snapshots * refactor(widgets): require explicit market providers * feat(widgets): stream shared trading portfolio data Co-authored-by: Codex * refactor(market): centralize TradingGoose Market requests Co-authored-by: Codex * refactor(auth): normalize credential service resolution Co-authored-by: Codex * refactor(trading): route portfolio requests by credential service Co-authored-by: Codex * refactor(widgets): persist trading credential services Co-authored-by: Codex * docs(changelog): update April feature notes Co-authored-by: Codex * fix(workflow): clarify output select icon state * refactor(indicators): run chart indicators in browser Co-authored-by: Codex * refactor(widgets): simplify shared widget context * feat(widgets): refine portfolio widgets and workflow layout * test(workflow): update auto layout expectations * fix(trading): align Alpaca crypto availability Co-authored-by: Codex * docs(changelog): document portfolio widgets branch Co-authored-by: Codex * feat(portfolio): enhance trading performance window support and widget rendering --------- Co-authored-by: Codex --- .../market-preview/market-preview.tsx | 77 +- .../monitor-preview/fetch-listings.ts | 94 +- .../app/api/auth/[...all]/route.test.ts | 52 +- .../app/api/auth/oauth/connections/route.ts | 51 +- .../app/api/auth/oauth/credentials/route.ts | 8 +- .../api/auth/oauth/disconnect/route.test.ts | 23 - .../app/api/auth/oauth/disconnect/route.ts | 11 +- .../app/api/auth/oauth/token/route.ts | 14 +- .../app/api/auth/oauth/utils.test.ts | 94 ++ apps/tradinggoose/app/api/auth/oauth/utils.ts | 14 +- .../app/api/indicators/execute/route.ts | 304 ------ .../app/api/market/api-keys/shared.ts | 18 +- .../tradinggoose/app/api/market/proxy.test.ts | 69 +- apps/tradinggoose/app/api/market/proxy.ts | 148 +-- .../app/api/market/search/validation.ts | 9 +- .../api/providers/trading/order/route.test.ts | 581 ++++++++++++ .../app/api/providers/trading/order/route.ts | 383 ++++++++ .../app/api/providers/trading/order/types.ts | 30 + .../app/api/providers/trading/shared.ts | 175 ++++ .../api/workflows/[id]/autolayout/route.ts | 73 +- .../app/api/yaml/autolayout/route.ts | 26 +- .../dashboard/dashboard-client.test.tsx | 82 +- .../dashboard/dashboard-client.tsx | 102 ++- .../blocks/blocks/trading_action.ts | 14 +- .../blocks/blocks/trading_holdings.ts | 25 +- .../blocks/blocks/trading_order_detail.ts | 5 +- apps/tradinggoose/blocks/types.ts | 1 + .../components/icons/provider-icons.tsx | 12 +- .../selector/resolve-request.ts | 302 +----- apps/tradinggoose/components/ui/chart.tsx | 331 +++++++ apps/tradinggoose/components/ui/index.ts | 8 + .../hooks/queries/listing-resolution.ts | 44 + .../queries/market-quote-snapshots.test.tsx | 187 ++++ .../hooks/queries/market-quote-snapshots.ts | 299 ++++++ .../hooks/queries/oauth-connections.ts | 21 - .../hooks/queries/oauth-credentials.ts | 23 + .../queries/oauth-provider-availability.ts | 45 + .../hooks/queries/trading-portfolio.ts | 415 +++++++++ apps/tradinggoose/lib/auth.ts | 82 +- .../tradinggoose/lib/copilot/access-policy.ts | 16 +- .../lib/copilot/chat-replay-safety.test.ts | 34 + .../lib/copilot/chat-replay-safety.ts | 39 +- .../lib/copilot/inline-tool-call.test.tsx | 50 + .../lib/copilot/inline-tool-call.tsx | 75 +- .../lib/copilot/runtime-tool-manifest.test.ts | 20 +- .../lib/copilot/tools/client/base-tool.ts | 4 - .../tools/client/server-tool-metadata.ts | 255 ++++++ .../tools/client/workflow/deploy-workflow.ts | 2 - .../workflow/edit-workflow-block.test.ts | 4 +- .../tools/client/workflow/edit-workflow.ts | 25 +- .../client/workflow/get-workflow-from-name.ts | 3 - .../tools/client/workflow/run-workflow.ts | 1 - .../lib/indicators/browser-execution.ts | 187 ++++ .../lib/indicators/trigger-bridge.ts | 203 +--- .../lib/indicators/trigger-capture.ts | 177 ++++ apps/tradinggoose/lib/json/stable.test.ts | 12 + apps/tradinggoose/lib/json/stable.ts | 29 + apps/tradinggoose/lib/listing/hydrate-ui.ts | 18 +- .../tradinggoose/lib/listing/identity.test.ts | 21 + apps/tradinggoose/lib/listing/identity.ts | 7 +- apps/tradinggoose/lib/listing/resolve.test.ts | 141 +++ apps/tradinggoose/lib/listing/resolve.ts | 265 ++++-- apps/tradinggoose/lib/market/client/client.ts | 26 +- .../market/market-provider-settings.test.ts | 58 ++ .../lib/market/market-provider-settings.ts | 88 ++ .../lib/market/quote-snapshot-contract.ts | 19 + .../lib/market/quote-snapshots.test.ts | 80 ++ .../lib/market/quote-snapshots.ts | 134 +++ .../lib/market/request-gate.test.ts | 173 ++++ apps/tradinggoose/lib/market/request-gate.ts | 98 ++ apps/tradinggoose/lib/oauth/oauth.test.ts | 52 +- apps/tradinggoose/lib/oauth/oauth.ts | 106 ++- .../lib/system-integrations/resolver.ts | 27 +- .../tradinggoose/lib/watchlists/operations.ts | 26 +- .../lib/workflows/autolayout/containers.ts | 5 +- .../lib/workflows/autolayout/incremental.ts | 131 --- .../lib/workflows/autolayout/index.ts | 46 +- .../lib/workflows/autolayout/positioning.ts | 184 ++-- .../lib/workflows/autolayout/targeted.ts | 317 ------- .../lib/workflows/autolayout/types.ts | 28 - .../lib/workflows/block-availability.ts | 68 +- .../workflows/studio-workflow-mermaid.test.ts | 47 + .../lib/workflows/studio-workflow-mermaid.ts | 128 +-- .../lib/workflows/workflow-direction.ts | 27 +- apps/tradinggoose/package.json | 1 + .../providers/market/alpha-vantage/config.ts | 6 + .../market/market-hours/market-hours-api.ts | 180 ++-- .../providers/market/providers.ts | 2 + .../providers/market/yahoo-finance/config.ts | 6 + .../providers/trading/alpaca/accounts.ts | 112 +++ .../providers/trading/alpaca/auth.ts | 21 +- .../providers/trading/alpaca/config.ts | 56 +- .../providers/trading/alpaca/orderDetail.ts | 27 +- .../providers/trading/alpaca/orders.test.ts | 3 +- .../providers/trading/alpaca/orders.ts | 19 +- .../providers/trading/alpaca/performance.ts | 196 ++++ .../trading/alpaca/portfolio.test.ts | 275 ++++++ .../trading/alpaca/positions.test.ts | 43 + .../providers/trading/alpaca/positions.ts | 103 ++- .../providers/trading/alpaca/snapshot.ts | 78 ++ apps/tradinggoose/providers/trading/index.ts | 5 +- .../trading/listing-resolution.test.ts | 127 +++ .../providers/trading/listing-resolution.ts | 176 ++++ .../providers/trading/order-types.test.ts | 87 ++ .../providers/trading/order-types.ts | 80 +- .../providers/trading/order-validation.ts | 17 + .../providers/trading/portfolio-utils.test.ts | 49 + .../providers/trading/portfolio-utils.ts | 161 ++++ .../providers/trading/portfolio.test.ts | 28 + .../providers/trading/portfolio.ts | 66 ++ .../providers/trading/providers.ts | 112 ++- .../providers/trading/tradier/accounts.ts | 67 ++ .../providers/trading/tradier/config.ts | 1 + .../providers/trading/tradier/orderDetail.ts | 9 +- .../providers/trading/tradier/orders.test.ts | 60 ++ .../providers/trading/tradier/performance.ts | 147 +++ .../trading/tradier/portfolio.test.ts | 175 ++++ .../providers/trading/tradier/positions.ts | 142 +-- .../providers/trading/tradier/snapshot.ts | 118 +++ apps/tradinggoose/providers/trading/types.ts | 58 +- .../providers/trading/utils.test.ts | 244 +++-- apps/tradinggoose/providers/trading/utils.ts | 162 +++- .../socket-server/handlers/index.ts | 3 + .../socket-server/handlers/market.ts | 15 +- .../socket-server/handlers/trading.ts | 82 ++ .../socket-server/market/manager.test.ts | 263 +++++- .../socket-server/market/manager.ts | 493 +++++++++- .../trading/portfolio-manager.test.ts | 340 +++++++ .../trading/portfolio-manager.ts | 753 +++++++++++++++ .../stores/copilot/store-state.ts | 27 +- .../tradinggoose/stores/copilot/store.test.ts | 4 +- apps/tradinggoose/stores/copilot/store.ts | 151 +-- apps/tradinggoose/stores/copilot/streaming.ts | 20 +- .../stores/copilot/tool-registry.ts | 50 +- apps/tradinggoose/stores/copilot/types.ts | 4 - .../stores/dashboard/pair-store.test.ts | 236 ++--- .../stores/dashboard/pair-store.ts | 120 +-- .../stores/workflows/registry/store.ts | 8 +- apps/tradinggoose/tools/index.ts | 16 +- .../tradinggoose/tools/trading/action.test.ts | 1 - apps/tradinggoose/tools/trading/action.ts | 60 +- apps/tradinggoose/tools/trading/holdings.ts | 47 +- .../tools/trading/order_detail.ts | 39 +- apps/tradinggoose/tools/trading/types.ts | 13 +- apps/tradinggoose/widgets/events.ts | 22 + .../hooks/use-workflow-widget-state.ts | 30 +- apps/tradinggoose/widgets/layout.test.ts | 71 +- apps/tradinggoose/widgets/layout.ts | 51 +- apps/tradinggoose/widgets/registry.test.ts | 49 + apps/tradinggoose/widgets/registry.tsx | 14 +- apps/tradinggoose/widgets/types.ts | 2 +- .../widgets/utils/chart-params.test.tsx | 107 +++ .../widgets/utils/chart-params.ts | 61 +- .../widgets/utils/heatmap-params.test.ts | 32 + .../widgets/utils/heatmap-params.ts | 164 ++++ .../utils/portfolio-snapshot-params.test.tsx | 113 +++ .../utils/portfolio-snapshot-params.ts | 199 ++++ .../widgets/utils/quick-order-params.test.tsx | 180 ++++ .../widgets/utils/quick-order-params.ts | 142 +++ .../utils/trading-widget-providers.test.ts | 18 + .../widgets/utils/trading-widget-providers.ts | 29 + .../widgets/utils/watchlist-params.test.tsx | 127 +++ .../widgets/utils/watchlist-params.ts | 105 ++- .../widgets/components/listing-selector.tsx | 124 +-- .../components/market-provider-controls.tsx | 64 ++ .../market-provider-selector.test.tsx | 66 ++ .../components/market-provider-selector.tsx | 45 +- .../market-provider-settings-button.test.tsx | 182 ++++ .../market-provider-settings-button.tsx | 343 +++++++ .../trading-account-selector.test.tsx | 180 ++++ .../components/trading-account-selector.tsx | 271 ++++++ .../components/trading-credential-services.ts | 68 ++ .../components/trading-provider-controls.tsx | 72 ++ .../trading-provider-selector.test.tsx | 66 ++ .../components/trading-provider-selector.tsx | 156 ++++ .../components/widget-header-control.ts | 2 +- .../widget-header-refresh-button.tsx | 39 + .../components/widget-selector.test.tsx | 67 ++ .../widgets/components/widget-selector.tsx | 25 +- .../widgets/components/workflow-dropdown.tsx | 50 +- .../copilot/components/copilot-app.test.tsx | 271 +----- .../copilot/components/copilot-app.tsx | 22 +- .../components/assistant-message-segments.ts | 24 +- .../widgets/copilot/live-contexts.test.ts | 36 +- .../widgets/widgets/copilot/live-contexts.ts | 54 +- .../widgets/data_chart/components/header.tsx | 41 +- .../components/provider-controls.tsx | 374 ++------ .../data_chart/hooks/use-chart-data-loader.ts | 13 +- .../data_chart/hooks/use-indicator-sync.ts | 172 +--- .../widgets/widgets/data_chart/index.tsx | 2 +- .../widgets/widgets/data_chart/options.ts | 35 +- .../widgets/editor_custom_tool/index.test.tsx | 6 +- .../widgets/editor_custom_tool/index.tsx | 95 +- .../components/indicator-editor-header.tsx | 8 +- .../editor-indicator-body.tsx | 16 +- .../components/skill-editor-header.tsx | 4 +- .../editor_skill/editor-skill-body.tsx | 20 +- .../components/control-bar/auto-layout.ts | 109 +-- .../components/control-bar/control-bar.tsx | 1 - .../components/oauth-required-modal.test.tsx | 72 ++ .../components/oauth-required-modal.tsx | 72 +- .../credential-selector.tsx | 197 ++-- .../workflow-editor/workflow-canvas.tsx | 4 - .../widgets/widgets/editor_workflow/index.tsx | 4 + .../entity_review/resolve-entity-id.ts | 9 +- .../entity_review/review-target-utils.test.ts | 33 +- .../entity_review/review-target-utils.ts | 43 +- .../use-resolved-review-target.test.tsx | 32 +- .../widgets/heatmap/components/body.test.tsx | 603 ++++++++++++ .../widgets/heatmap/components/body.tsx | 419 +++++++++ .../heatmap/components/header.test.tsx | 283 ++++++ .../widgets/heatmap/components/header.tsx | 234 +++++ .../components/heatmap-treemap-chart.test.tsx | 502 ++++++++++ .../components/heatmap-treemap-chart.tsx | 348 +++++++ .../widgets/heatmap/components/shared.test.ts | 45 + .../widgets/heatmap/components/shared.ts | 59 ++ .../heatmap/components/source-items.test.ts | 135 +++ .../heatmap/components/source-items.ts | 79 ++ .../widgets/widgets/heatmap/index.tsx | 16 + .../widgets/widgets/heatmap/types.ts | 20 + .../widgets/heatmap/utils/color.test.ts | 25 + .../widgets/widgets/heatmap/utils/color.ts | 49 + .../widgets/heatmap/utils/format.test.ts | 16 + .../widgets/widgets/heatmap/utils/format.ts | 14 + .../heatmap/utils/treemap-layout.test.ts | 99 ++ .../widgets/heatmap/utils/treemap-layout.ts | 176 ++++ .../indicator-list/indicator-list.tsx | 6 - .../components/body.test.tsx | 748 +++++++++++++++ .../portfolio_snapshot/components/body.tsx | 735 +++++++++++++++ .../components/header.test.tsx | 276 ++++++ .../portfolio_snapshot/components/header.tsx | 164 ++++ .../components/performance-chart.tsx | 150 +++ .../portfolio_snapshot/components/shared.ts | 44 + .../widgets/portfolio_snapshot/index.tsx | 16 + .../widgets/portfolio_snapshot/types.ts | 18 + .../quick_order/components/body.test.tsx | 629 +++++++++++++ .../widgets/quick_order/components/body.tsx | 865 ++++++++++++++++++ .../quick_order/components/header.test.tsx | 369 ++++++++ .../widgets/quick_order/components/header.tsx | 173 ++++ .../quick_order/components/shared.test.ts | 148 +++ .../widgets/quick_order/components/shared.ts | 231 +++++ .../widgets/widgets/quick_order/index.test.ts | 13 + .../widgets/widgets/quick_order/index.tsx | 14 + .../widgets/widgets/quick_order/types.ts | 15 + .../components/stock-selector-dropdown.tsx | 119 --- .../watchlist/components/stock-selector.tsx | 515 ----------- .../components/watchlist-body.test.tsx | 90 +- .../watchlist/components/watchlist-body.tsx | 60 +- .../watchlist-header-controls.test.ts | 12 +- .../components/watchlist-header-controls.tsx | 109 +-- .../watchlist-header-controls.ui.test.tsx | 21 + ...watchlist-header-left-controls.ui.test.tsx | 32 +- ...sx => watchlist-listing-selector.test.tsx} | 6 +- .../components/watchlist-listing-selector.tsx | 20 + .../watchlist-refresh-data-button.tsx | 30 - .../watchlist/components/watchlist-table.tsx | 8 +- .../widgets/widgets/watchlist/index.tsx | 2 +- .../watchlist-table.sections.test.tsx | 46 +- .../output-select/output-select.tsx | 24 +- bun.lock | 59 ++ changelog/April-20-2026.md | 1 + changelog/May-03-2026.md | 88 ++ 262 files changed, 22168 insertions(+), 5587 deletions(-) delete mode 100644 apps/tradinggoose/app/api/indicators/execute/route.ts create mode 100644 apps/tradinggoose/app/api/providers/trading/order/route.test.ts create mode 100644 apps/tradinggoose/app/api/providers/trading/order/route.ts create mode 100644 apps/tradinggoose/app/api/providers/trading/order/types.ts create mode 100644 apps/tradinggoose/app/api/providers/trading/shared.ts create mode 100644 apps/tradinggoose/components/ui/chart.tsx create mode 100644 apps/tradinggoose/hooks/queries/listing-resolution.ts create mode 100644 apps/tradinggoose/hooks/queries/market-quote-snapshots.test.tsx create mode 100644 apps/tradinggoose/hooks/queries/market-quote-snapshots.ts create mode 100644 apps/tradinggoose/hooks/queries/oauth-provider-availability.ts create mode 100644 apps/tradinggoose/hooks/queries/trading-portfolio.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts create mode 100644 apps/tradinggoose/lib/indicators/browser-execution.ts create mode 100644 apps/tradinggoose/lib/indicators/trigger-capture.ts create mode 100644 apps/tradinggoose/lib/json/stable.test.ts create mode 100644 apps/tradinggoose/lib/json/stable.ts create mode 100644 apps/tradinggoose/lib/listing/identity.test.ts create mode 100644 apps/tradinggoose/lib/listing/resolve.test.ts create mode 100644 apps/tradinggoose/lib/market/market-provider-settings.test.ts create mode 100644 apps/tradinggoose/lib/market/market-provider-settings.ts create mode 100644 apps/tradinggoose/lib/market/quote-snapshot-contract.ts create mode 100644 apps/tradinggoose/lib/market/quote-snapshots.test.ts create mode 100644 apps/tradinggoose/lib/market/quote-snapshots.ts create mode 100644 apps/tradinggoose/lib/market/request-gate.test.ts create mode 100644 apps/tradinggoose/lib/market/request-gate.ts delete mode 100644 apps/tradinggoose/lib/workflows/autolayout/incremental.ts delete mode 100644 apps/tradinggoose/lib/workflows/autolayout/targeted.ts create mode 100644 apps/tradinggoose/providers/trading/alpaca/accounts.ts create mode 100644 apps/tradinggoose/providers/trading/alpaca/performance.ts create mode 100644 apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts create mode 100644 apps/tradinggoose/providers/trading/alpaca/positions.test.ts create mode 100644 apps/tradinggoose/providers/trading/alpaca/snapshot.ts create mode 100644 apps/tradinggoose/providers/trading/listing-resolution.test.ts create mode 100644 apps/tradinggoose/providers/trading/listing-resolution.ts create mode 100644 apps/tradinggoose/providers/trading/order-types.test.ts create mode 100644 apps/tradinggoose/providers/trading/order-validation.ts create mode 100644 apps/tradinggoose/providers/trading/portfolio-utils.test.ts create mode 100644 apps/tradinggoose/providers/trading/portfolio-utils.ts create mode 100644 apps/tradinggoose/providers/trading/portfolio.test.ts create mode 100644 apps/tradinggoose/providers/trading/portfolio.ts create mode 100644 apps/tradinggoose/providers/trading/tradier/accounts.ts create mode 100644 apps/tradinggoose/providers/trading/tradier/orders.test.ts create mode 100644 apps/tradinggoose/providers/trading/tradier/performance.ts create mode 100644 apps/tradinggoose/providers/trading/tradier/portfolio.test.ts create mode 100644 apps/tradinggoose/providers/trading/tradier/snapshot.ts create mode 100644 apps/tradinggoose/socket-server/handlers/trading.ts create mode 100644 apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts create mode 100644 apps/tradinggoose/socket-server/trading/portfolio-manager.ts create mode 100644 apps/tradinggoose/widgets/registry.test.ts create mode 100644 apps/tradinggoose/widgets/utils/chart-params.test.tsx create mode 100644 apps/tradinggoose/widgets/utils/heatmap-params.test.ts create mode 100644 apps/tradinggoose/widgets/utils/heatmap-params.ts create mode 100644 apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx create mode 100644 apps/tradinggoose/widgets/utils/portfolio-snapshot-params.ts create mode 100644 apps/tradinggoose/widgets/utils/quick-order-params.test.tsx create mode 100644 apps/tradinggoose/widgets/utils/quick-order-params.ts create mode 100644 apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts create mode 100644 apps/tradinggoose/widgets/utils/trading-widget-providers.ts create mode 100644 apps/tradinggoose/widgets/utils/watchlist-params.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/market-provider-controls.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/market-provider-selector.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/trading-account-selector.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/trading-account-selector.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/trading-credential-services.ts create mode 100644 apps/tradinggoose/widgets/widgets/components/trading-provider-controls.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/trading-provider-selector.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx create mode 100644 apps/tradinggoose/widgets/widgets/components/widget-selector.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/body.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/shared.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/source-items.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/components/source-items.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/index.tsx create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/types.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/utils/color.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/utils/color.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/utils/format.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/utils/format.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/heatmap/utils/treemap-layout.ts create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/performance-chart.tsx create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/index.tsx create mode 100644 apps/tradinggoose/widgets/widgets/portfolio_snapshot/types.ts create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/components/header.tsx create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/components/shared.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/components/shared.ts create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/index.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/index.tsx create mode 100644 apps/tradinggoose/widgets/widgets/quick_order/types.ts delete mode 100644 apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector-dropdown.tsx delete mode 100644 apps/tradinggoose/widgets/widgets/watchlist/components/stock-selector.tsx rename apps/tradinggoose/widgets/widgets/watchlist/components/{stock-selector.test.tsx => watchlist-listing-selector.test.tsx} (93%) create mode 100644 apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx delete mode 100644 apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-refresh-data-button.tsx create mode 100644 changelog/May-03-2026.md diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx index e1cc559d4..57bb1220f 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx @@ -1,9 +1,8 @@ 'use client' import React from 'react' -import { Indicator, PineTS } from 'pinets' +import { executeBrowserPineIndicator } from '@/lib/indicators/browser-execution' import { buildInputsMapFromMeta } from '@/lib/indicators/input-meta' -import { normalizeContext } from '@/lib/indicators/normalize-context' import { buildIndexMaps, mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' import type { BarMs, NormalizedPineOutput } from '@/lib/indicators/types' import type { ListingOption } from '@/lib/listing/identity' @@ -70,32 +69,6 @@ const LANDING_MARKET_LISTING: ListingOption = { const BACKFILL_CHUNK_BARS = 1000 const BACKFILL_WINDOW_SEGMENTS = 3 -let landingPreviewTriggerShimLock: Promise = Promise.resolve() - -const acquireLandingPreviewTriggerShim = async () => { - const previousLock = landingPreviewTriggerShimLock - let releaseLock: () => void = () => {} - landingPreviewTriggerShimLock = new Promise((resolve) => { - releaseLock = resolve - }) - await previousLock - - const previousTrigger = (globalThis as { trigger?: (() => void) | undefined }).trigger - ;(globalThis as { trigger?: () => void }).trigger = () => { - // The landing preview does not collect trigger payloads; it only needs the - // global symbol to exist while client-side PineTS evaluates trigger(). - } - - return () => { - if (previousTrigger === undefined) { - ;(globalThis as { trigger?: () => void }).trigger = undefined - } else { - ;(globalThis as { trigger?: () => void }).trigger = previousTrigger - } - releaseLock() - } -} - type IndicatorExecutionState = { status: 'loading' | 'ready' | 'error' output: NormalizedPineOutput | null @@ -471,53 +444,19 @@ export function MarketPreview() { } try { - const pine = new PineTS(bars, 'SIM:GOOSE', '1m') - await pine.ready() const inputsMap = buildInputsMapFromMeta( indicator.definition.inputMeta, ref.inputs ?? undefined ) - - let context: any - const requiresTriggerShim = indicator.definition.pineCode.includes('trigger(') - let releaseTriggerShim: (() => void) | null = null - if (requiresTriggerShim) { - releaseTriggerShim = await acquireLandingPreviewTriggerShim() - } - try { - context = await pine.run(new Indicator(indicator.definition.pineCode, inputsMap)) - } finally { - releaseTriggerShim?.() - } - - const { output, warnings } = normalizeContext({ - context, - ...buildIndexMaps(bars), - triggerSignals: [], + const { output, warnings } = await executeBrowserPineIndicator({ + barsMs: bars, + pineCode: indicator.definition.pineCode, + inputsMap, + inputMeta: indicator.definition.inputMeta, + symbol: 'SIM:GOOSE', + interval: '1m', }) - // Post-process: apply input.bool visibility toggles that client-side - // PineTS can't handle in ternaries (TimeSeries objects are always truthy). - // Check each bool input — if its title matches "Show X line" and the value - // is false, null out the matching plot's data points so the line disappears - // but the series (and its pane control) remains. - if (output && indicator.definition.inputMeta) { - const meta = indicator.definition.inputMeta - Object.entries(meta).forEach(([title, inputDef]) => { - if (inputDef.type !== 'bool') return - const inputValue = inputsMap[title] - if (inputValue !== false && inputValue !== 0) return - const match = title.match(/^show\s+(.+?)(?:\s+line)?$/i) - if (!match) return - const plotName = match[1].toLowerCase() - output.series.forEach((s) => { - if (s.plot.title.toLowerCase().includes(plotName)) { - s.points = s.points.map((p) => ({ ...p, value: null })) - } - }) - }) - } - return [ ref.id, { diff --git a/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts b/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts index 5f2efce67..115fa8621 100644 --- a/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts +++ b/apps/tradinggoose/app/(landing)/components/monitor-preview/fetch-listings.ts @@ -1,5 +1,4 @@ import { normalizeListingOptions } from '@/components/listing-selector/fetchers' -import { readServerJsonCache, writeServerJsonCache } from '@/lib/cache/server-json-cache' import type { ListingOption } from '@/lib/listing/identity' import { marketClient } from '@/lib/market/client/client' import { MARKET_API_VERSION } from '@/lib/market/client/constants' @@ -9,26 +8,9 @@ import { sortMonitorListings, } from '@/app/(landing)/components/monitor-preview/listing-preference' -// Stale-while-revalidate window: -// - Data stays cached for CACHE_TTL (24h) so we never serve absolutely cold. -// - A request is considered "fresh" if fetched within FRESH_WINDOW (15 min). -// - When stale-but-cached, we return immediately and kick off a background -// revalidation without blocking the SSR render. -const MONITOR_LISTINGS_CACHE_TTL_SECONDS = 24 * 60 * 60 -const MONITOR_LISTINGS_FRESH_WINDOW_MS = 15 * 60 * 1000 -// v6: switched to one request per PREFERRED_MARKET_CODES with per-market limit. -const MONITOR_LISTINGS_CACHE_KEY = 'landing-monitor-listings:v6' const MONITOR_PREVIEW_ROW_LIMIT = 20 const MONITOR_LISTINGS_TIMEOUT_MS = 4000 -type CachedEnvelope = { - data: ListingOption[] - fetchedAt: number -} - -// Deduplicate concurrent revalidations across simultaneous SSR requests. -let inflightRevalidation: Promise | null = null - // Per-market result budget. Total pool = PER_MARKET_LIMIT * markets (5) = 50. const MONITOR_PER_MARKET_LIMIT = 10 @@ -44,28 +26,6 @@ function buildMarketQuery(marketCode: string): string { }).toString() } -const FALLBACK_STOCKS: ListingOption[] = [ - ['AAPL', 'Apple Inc.', 'stock'], - ['TSM', 'Taiwan Semiconductor', 'stock'], - ['ASML', 'ASML Holding', 'stock'], - ['SONY', 'Sony Group', 'stock'], - ['SAP', 'SAP SE', 'stock'], - ['NVO', 'Novo Nordisk', 'stock'], - ['BABA', 'Alibaba Group', 'stock'], - ['SHOP', 'Shopify', 'stock'], - ['MELI', 'MercadoLibre', 'stock'], - ['RELX', 'RELX', 'stock'], -].map(([base, name, assetClass]) => ({ - listing_id: `fallback-${base.toLowerCase()}`, - base_id: '', - quote_id: '', - listing_type: 'default' as const, - base, - name, - iconUrl: '', - assetClass, -})) - async function requestMarketListings(marketCode: string): Promise { const response = await marketClient.makeRequest<{ data?: ListingOption[] | ListingOption | null @@ -84,61 +44,13 @@ async function requestMonitorListings(): Promise { PREFERRED_MARKET_CODES.map((code) => requestMarketListings(code)) ) - const combined = results.flatMap((result) => - result.status === 'fulfilled' ? result.value : [] - ) + const combined = results.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) // filterToPreferredMarkets is belt-and-suspenders in case the upstream // ignored the market filter and returned listings from other exchanges. - return sortMonitorListings(filterToPreferredMarkets(combined)).slice( - 0, - MONITOR_PREVIEW_ROW_LIMIT - ) -} - -async function revalidateCache(): Promise { - if (inflightRevalidation) return inflightRevalidation - - inflightRevalidation = (async () => { - try { - const listings = await requestMonitorListings() - if (listings.length > 0) { - const envelope: CachedEnvelope = { data: listings, fetchedAt: Date.now() } - await writeServerJsonCache( - MONITOR_LISTINGS_CACHE_KEY, - envelope, - MONITOR_LISTINGS_CACHE_TTL_SECONDS - ) - return listings - } - return [] - } catch { - return [] - } finally { - inflightRevalidation = null - } - })() - - return inflightRevalidation + return sortMonitorListings(filterToPreferredMarkets(combined)).slice(0, MONITOR_PREVIEW_ROW_LIMIT) } export async function fetchMonitorStocks(): Promise { - const cached = await readServerJsonCache(MONITOR_LISTINGS_CACHE_KEY) - - if (cached && Array.isArray(cached.data) && cached.data.length > 0) { - const isStale = Date.now() - cached.fetchedAt > MONITOR_LISTINGS_FRESH_WINDOW_MS - if (isStale) { - // Stale-while-revalidate: serve the cached data immediately, refresh - // in the background without blocking this response. - void revalidateCache() - } - return cached.data.slice(0, MONITOR_PREVIEW_ROW_LIMIT) - } - - // No cache at all — first ever request or Redis miss. Don't let a slow - // upstream stall the SSR: kick off the revalidation in the background and - // serve FALLBACK_STOCKS immediately. The client-side refresh in - // monitor-preview.tsx will populate real data once the fetch lands. - void revalidateCache() - return FALLBACK_STOCKS + return requestMonitorListings().catch(() => []) } diff --git a/apps/tradinggoose/app/api/auth/[...all]/route.test.ts b/apps/tradinggoose/app/api/auth/[...all]/route.test.ts index ab0530d24..75509a713 100644 --- a/apps/tradinggoose/app/api/auth/[...all]/route.test.ts +++ b/apps/tradinggoose/app/api/auth/[...all]/route.test.ts @@ -13,8 +13,8 @@ const { mockAuthHandler: vi.fn(), mockLoadSystemOAuthClientCredentials: vi.fn(), mockRunWithSystemOAuthClientCredentials: vi.fn(), - mockIsSignInOAuthProviderId: vi.fn((providerId: string) => - providerId === 'github' || providerId === 'google' + mockIsSignInOAuthProviderId: vi.fn( + (providerId: string) => providerId === 'github' || providerId === 'google' ), })) @@ -107,6 +107,54 @@ describe('/api/auth/[...all] route', () => { expect(mockAuthHandler).toHaveBeenCalledTimes(1) }) + it('hydrates Alpaca paper credentials before delegating OAuth link routes', async () => { + mockAuthHandler.mockResolvedValue(new Response(null, { status: 204 })) + mockLoadSystemOAuthClientCredentials.mockResolvedValue({ + 'alpaca-paper': { + clientId: 'client-id', + clientSecret: 'client-secret', + }, + }) + + const { handleAuthRequest } = await import('./route') + const response = await handleAuthRequest( + new Request('http://localhost/api/auth/oauth2/link', { + method: 'POST', + body: JSON.stringify({ + providerId: 'alpaca-paper', + callbackURL: 'http://localhost/workspace', + }), + }) + ) + + expect(response.status).toBe(204) + expect(mockLoadSystemOAuthClientCredentials).toHaveBeenCalledWith(['alpaca-paper']) + expect(mockRunWithSystemOAuthClientCredentials).toHaveBeenCalledTimes(1) + expect(mockAuthHandler).toHaveBeenCalledTimes(1) + }) + + it('hydrates Alpaca paper credentials before delegating OAuth callback routes', async () => { + mockAuthHandler.mockResolvedValue(new Response(null, { status: 204 })) + mockLoadSystemOAuthClientCredentials.mockResolvedValue({ + 'alpaca-paper': { + clientId: 'client-id', + clientSecret: 'client-secret', + }, + }) + + const { handleAuthRequest } = await import('./route') + const response = await handleAuthRequest( + new Request('http://localhost/api/auth/oauth2/callback/alpaca-paper?code=code', { + method: 'GET', + }) + ) + + expect(response.status).toBe(204) + expect(mockLoadSystemOAuthClientCredentials).toHaveBeenCalledWith(['alpaca-paper']) + expect(mockRunWithSystemOAuthClientCredentials).toHaveBeenCalledTimes(1) + expect(mockAuthHandler).toHaveBeenCalledTimes(1) + }) + it('returns 400 when a system oauth callback provider is not configured', async () => { const { handleAuthRequest } = await import('./route') const response = await handleAuthRequest( diff --git a/apps/tradinggoose/app/api/auth/oauth/connections/route.ts b/apps/tradinggoose/app/api/auth/oauth/connections/route.ts index 6da972088..b68b4c196 100644 --- a/apps/tradinggoose/app/api/auth/oauth/connections/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/connections/route.ts @@ -90,45 +90,22 @@ export async function GET(request: NextRequest) { displayName = `${acc.accountId} (${baseProvider})` } - // Create a unique connection key that includes the full provider ID const connectionKey = acc.providerId - // Find existing connection for this specific provider ID - const existingConnection = connections.find((conn) => conn.provider === connectionKey) - - const accountSummary = { - id: acc.id, - name: displayName, - } - - if (existingConnection) { - // Add account to existing connection - existingConnection.accounts = existingConnection.accounts || [] - existingConnection.accounts.push(accountSummary) - existingConnection.scopes = Array.from( - new Set([...(existingConnection.scopes || []), ...scopes]) - ) - - const existingTimestamp = existingConnection.lastConnected - ? new Date(existingConnection.lastConnected).getTime() - : 0 - const candidateTimestamp = acc.updatedAt.getTime() - - if (candidateTimestamp > existingTimestamp) { - existingConnection.lastConnected = acc.updatedAt.toISOString() - } - } else { - // Create new connection - connections.push({ - provider: connectionKey, - baseProvider, - featureType, - isConnected: true, - scopes, - lastConnected: acc.updatedAt.toISOString(), - accounts: [accountSummary], - }) - } + connections.push({ + provider: connectionKey, + baseProvider, + featureType, + isConnected: true, + scopes, + lastConnected: acc.updatedAt.toISOString(), + accounts: [ + { + id: acc.id, + name: displayName, + }, + ], + }) } } diff --git a/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts b/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts index 625b89198..2e2314765 100644 --- a/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/credentials/route.ts @@ -5,12 +5,7 @@ import { jwtDecode } from 'jwt-decode' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' -import { - OAUTH_PROVIDERS, - type OAuthProvider, - type OAuthService, - parseProvider, -} from '@/lib/oauth' +import { OAUTH_PROVIDERS, type OAuthProvider, type OAuthService, parseProvider } from '@/lib/oauth' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' @@ -45,6 +40,7 @@ function toCredentialResponse( id: acc.id, name: displayName, provider: acc.providerId, + serviceId: featureType, lastUsed: acc.updatedAt.toISOString(), isDefault, scopes, diff --git a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts index 5eca47f3d..c673d2e4f 100644 --- a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.test.ts @@ -101,29 +101,6 @@ describe('OAuth Disconnect API Route', () => { expect(mockLogger.info).toHaveBeenCalled() }) - it('should disconnect a specific account row successfully', async () => { - mockGetSession.mockResolvedValueOnce({ - user: { id: 'user-123' }, - }) - - mockDb.delete.mockReturnValueOnce(mockDb) - mockDb.where.mockResolvedValueOnce(undefined) - - const req = createMockRequest('POST', { - provider: 'google', - accountId: 'account-123', - }) - - const { POST } = await import('@/app/api/auth/oauth/disconnect/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockLogger.info).toHaveBeenCalled() - }) - it('should handle unauthenticated user', async () => { mockGetSession.mockResolvedValueOnce(null) diff --git a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts index 5bbe87f6f..27a1c0719 100644 --- a/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/disconnect/route.ts @@ -26,8 +26,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - // Get the provider, providerId, and accountId from the request body - const { provider, providerId, accountId } = await request.json() + const { provider, providerId } = await request.json() if (!provider) { logger.warn(`[${requestId}] Missing provider in disconnect request`) @@ -39,13 +38,7 @@ export async function POST(request: NextRequest) { hasProviderId: !!providerId, }) - // If a specific account row ID is provided, delete that exact account - if (accountId) { - await db - .delete(account) - .where(and(eq(account.userId, session.user.id), eq(account.id, accountId))) - } else if (providerId) { - // If a specific providerId is provided, delete accounts for that provider ID + if (providerId) { await db .delete(account) .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) diff --git a/apps/tradinggoose/app/api/auth/oauth/token/route.ts b/apps/tradinggoose/app/api/auth/oauth/token/route.ts index 174c28d12..780d2d731 100644 --- a/apps/tradinggoose/app/api/auth/oauth/token/route.ts +++ b/apps/tradinggoose/app/api/auth/oauth/token/route.ts @@ -52,7 +52,12 @@ export async function POST(request: NextRequest) { const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const apiKey = credential.providerId === 'trello' ? await getTrelloApiKey() : undefined return NextResponse.json( - { accessToken, idToken: credential.idToken || undefined, apiKey }, + { + accessToken, + idToken: credential.idToken || undefined, + apiKey, + providerId: credential.providerId, + }, { status: 200 } ) } catch (error) { @@ -103,7 +108,12 @@ export async function GET(request: NextRequest) { const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const apiKey = credential.providerId === 'trello' ? await getTrelloApiKey() : undefined return NextResponse.json( - { accessToken, idToken: credential.idToken || undefined, apiKey }, + { + accessToken, + idToken: credential.idToken || undefined, + apiKey, + providerId: credential.providerId, + }, { status: 200 } ) } catch (_error) { diff --git a/apps/tradinggoose/app/api/auth/oauth/utils.test.ts b/apps/tradinggoose/app/api/auth/oauth/utils.test.ts index eecc4d54b..a170ff919 100644 --- a/apps/tradinggoose/app/api/auth/oauth/utils.test.ts +++ b/apps/tradinggoose/app/api/auth/oauth/utils.test.ts @@ -131,6 +131,100 @@ describe('OAuth Utils', () => { }) }) + describe('getOAuthToken', () => { + it('should return a valid access token for a single provider connection', async () => { + mockDb.limit.mockReturnValueOnce([ + { + id: 'credential-id', + accessToken: 'valid-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + }, + ]) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(token).toBe('valid-token') + expect(mockDb.limit).toHaveBeenCalledWith(2) + expect(mockDb.orderBy).not.toHaveBeenCalled() + }) + + it('should return null when no provider connection exists', async () => { + mockDb.limit.mockReturnValueOnce([]) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(token).toBeNull() + expect(mockLogger.warn).toHaveBeenCalledWith( + 'No OAuth token found for user test-user-id, provider alpaca' + ) + }) + + it('should reject duplicate provider connections instead of choosing one', async () => { + mockDb.limit.mockReturnValueOnce([ + { + id: 'credential-id-1', + accessToken: 'first-token', + refreshToken: 'refresh-token-1', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + }, + { + id: 'credential-id-2', + accessToken: 'second-token', + refreshToken: 'refresh-token-2', + accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), + }, + ]) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(token).toBeNull() + expect(mockRefreshOAuthToken).not.toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + 'Multiple OAuth connections found for user test-user-id, provider alpaca', + { + providerId: 'alpaca', + userId: 'test-user-id', + } + ) + }) + + it('should refresh an expired token for a single provider connection', async () => { + mockDb.limit.mockReturnValueOnce([ + { + id: 'credential-id', + accessToken: 'expired-token', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + }, + ]) + mockRefreshOAuthToken.mockResolvedValueOnce({ + accessToken: 'new-token', + expiresIn: 3600, + refreshToken: 'new-refresh-token', + }) + + const { getOAuthToken } = await import('@/app/api/auth/oauth/utils') + + const token = await getOAuthToken('test-user-id', 'alpaca') + + expect(mockRefreshOAuthToken).toHaveBeenCalledWith('alpaca', 'refresh-token') + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'new-token', + refreshToken: 'new-refresh-token', + }) + ) + expect(token).toBe('new-token') + }) + }) + describe('refreshTokenIfNeeded', () => { it('should return valid token without refresh if not expired', async () => { const mockCredential = { diff --git a/apps/tradinggoose/app/api/auth/oauth/utils.ts b/apps/tradinggoose/app/api/auth/oauth/utils.ts index b6d059a22..3e1cd9868 100644 --- a/apps/tradinggoose/app/api/auth/oauth/utils.ts +++ b/apps/tradinggoose/app/api/auth/oauth/utils.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { account, workflow } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { @@ -128,15 +128,21 @@ export async function getOAuthToken(userId: string, providerId: string): Promise }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) - // Always use the most recently updated credential for this provider - .orderBy(desc(account.updatedAt)) - .limit(1) + .limit(2) if (connections.length === 0) { logger.warn(`No OAuth token found for user ${userId}, provider ${providerId}`) return null } + if (connections.length > 1) { + logger.error(`Multiple OAuth connections found for user ${userId}, provider ${providerId}`, { + providerId, + userId, + }) + return null + } + const credential = connections[0] // Determine whether we should refresh: missing token OR expired token diff --git a/apps/tradinggoose/app/api/indicators/execute/route.ts b/apps/tradinggoose/app/api/indicators/execute/route.ts deleted file mode 100644 index 1a1a082c3..000000000 --- a/apps/tradinggoose/app/api/indicators/execute/route.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { db } from '@tradinggoose/db' -import { pineIndicators } from '@tradinggoose/db/schema' -import { and, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { - ExecutionGateError, - enforceServerExecutionRateLimit, - getExecutionConcurrencyLimitMessage, - isExecutionConcurrencyBackendUnavailableError, - isExecutionConcurrencyLimitError, - withExecutionConcurrencyLimit, -} from '@/lib/execution/execution-concurrency-limit' -import { getLocalVmSaturationLimitMessage, isLocalVmSaturationLimitError } from '@/lib/execution/local-saturation-limit' -import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' -import { executeCompiledIndicator } from '@/lib/indicators/execution/compile-execution' -import { buildInputsMapFromMeta, normalizeInputMetaMap } from '@/lib/indicators/input-meta' -import { mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' -import { toListingValueObject } from '@/lib/listing/identity' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { RateLimitError } from '@/services/queue' -import { - authenticateIndicatorRequest, - getWorkspaceWritePermissionError, - isExecutionTimeoutError, - parseIndicatorRequestBody, -} from '../utils' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 30 - -const logger = createLogger('IndicatorExecuteAPI') -const EXECUTION_TIMEOUT_MS = 15000 - -type IndicatorExecuteWarning = { - code: string - message: string -} - -type ExecuteResult = { - indicatorId: string - output: unknown | null - warnings: IndicatorExecuteWarning[] - unsupported: unknown - counts: { plots: number; markers: number; triggers: number } - executionError?: { message: string; code: string; unsupported?: unknown } -} - -const MarketBarSchema = z.object({ - timeStamp: z.string(), - open: z.number().optional(), - high: z.number().optional(), - low: z.number().optional(), - close: z.number(), - volume: z.number().optional(), - turnover: z.number().optional(), -}) - -const ListingIdentitySchema = z.object({ - listing_id: z.string(), - base_id: z.string(), - quote_id: z.string(), - listing_type: z.enum(['default', 'crypto', 'currency']), -}) - -const MarketSeriesSchema = z.object({ - listing: ListingIdentitySchema.nullable().optional(), - bars: z.array(MarketBarSchema).min(1, 'marketSeries.bars is required'), -}) - -const ExecuteSchema = z.object({ - workspaceId: z.string().min(1, 'workspaceId is required'), - indicatorIds: z.array(z.string().min(1)).min(1, 'indicatorIds is required'), - marketSeries: MarketSeriesSchema, - interval: z.string().optional(), - intervalMs: z.number().optional(), - inputsMapById: z.record(z.record(z.any())).optional(), -}) - -export async function POST(request: NextRequest) { - const requestId = generateRequestId() - - try { - const auth = await authenticateIndicatorRequest({ - request, - requestId, - logger, - action: 'execute', - }) - if ('response' in auth) return auth.response - - const parsedBody = await parseIndicatorRequestBody({ request, schema: ExecuteSchema }) - if ('response' in parsedBody) return parsedBody.response - - const { workspaceId, indicatorIds, interval, intervalMs } = parsedBody.data - - const permissionError = await getWorkspaceWritePermissionError(auth.userId, workspaceId) - if (permissionError) return permissionError - - await enforceServerExecutionRateLimit({ - actorUserId: auth.userId, - authType: auth.authType, - workspaceId, - isAsync: false, - logger, - requestId, - source: 'indicator execute', - }) - - const marketSeries = parsedBody.data.marketSeries - const barsMs = mapMarketSeriesToBarsMs(marketSeries, intervalMs ?? null) - const executionListing = toListingValueObject(marketSeries.listing ?? null) - - const customIndicatorIds = indicatorIds.filter((id) => !DEFAULT_INDICATOR_RUNTIME_MAP.has(id)) - const storedIndicators = - customIndicatorIds.length > 0 - ? await db - .select() - .from(pineIndicators) - .where( - and( - eq(pineIndicators.workspaceId, workspaceId), - inArray(pineIndicators.id, customIndicatorIds) - ) - ) - : [] - - const indicatorMap = new Map(storedIndicators.map((indicator) => [indicator.id, indicator])) - - const results = await withExecutionConcurrencyLimit({ - userId: auth.userId, - workspaceId, - task: async () => { - const nextResults: ExecuteResult[] = [] - - for (const indicatorId of indicatorIds) { - const customIndicator = indicatorMap.get(indicatorId) - const defaultIndicator = DEFAULT_INDICATOR_RUNTIME_MAP.get(indicatorId) - - if (!customIndicator && !defaultIndicator) { - nextResults.push({ - indicatorId, - output: null, - warnings: [{ code: 'missing_indicator', message: `${indicatorId} is missing.` }], - unsupported: { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { message: 'Indicator not found', code: 'missing_indicator' }, - }) - continue - } - - const pineCode = customIndicator?.pineCode ?? defaultIndicator?.pineCode ?? '' - const inputMeta = customIndicator - ? normalizeInputMetaMap(customIndicator.inputMeta) - : defaultIndicator?.inputMeta - const inputsOverride = parsedBody.data.inputsMapById?.[indicatorId] - const baseInputsMap = buildInputsMapFromMeta(inputMeta) - const inputsMap = inputsOverride ? { ...baseInputsMap, ...inputsOverride } : baseInputsMap - - try { - const compiled = await executeCompiledIndicator({ - pineCode, - barsMs, - inputsMap, - listing: executionListing, - interval, - intervalMs, - executionTimeoutMs: EXECUTION_TIMEOUT_MS, - userId: auth.userId, - }) - - if (compiled.unsupportedFeatures && compiled.unsupportedFeatures.length > 0) { - nextResults.push({ - indicatorId, - output: null, - warnings: compiled.warnings, - unsupported: { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { - message: `${compiled.unsupportedFeatures[0]} is not supported`, - code: 'unsupported_feature', - unsupported: { features: compiled.unsupportedFeatures }, - }, - }) - continue - } - - if (!compiled.output) { - nextResults.push({ - indicatorId, - output: null, - warnings: compiled.warnings, - unsupported: compiled.unsupported ?? { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { - message: compiled.executionError?.message ?? 'Failed to execute indicator', - code: 'runtime_error', - }, - }) - continue - } - - const output = compiled.output - nextResults.push({ - indicatorId, - output, - warnings: compiled.warnings, - unsupported: output.unsupported, - counts: { - plots: output.series.length, - markers: output.markers.length, - triggers: output.triggers.length, - }, - }) - } catch (error) { - if (isLocalVmSaturationLimitError(error)) { - throw error - } - - const timedOut = isExecutionTimeoutError(error) - nextResults.push({ - indicatorId, - output: null, - warnings: [], - unsupported: { plots: [], styles: [] }, - counts: { plots: 0, markers: 0, triggers: 0 }, - executionError: { - message: timedOut ? 'Execution timed out' : 'Failed to execute indicator', - code: timedOut ? 'timeout' : 'runtime_error', - }, - }) - } - } - - return nextResults - }, - }) - - return NextResponse.json({ success: true, data: results }) - } catch (error) { - if (error instanceof ExecutionGateError) { - return NextResponse.json( - { - success: false, - error: error.message, - code: 'usage_limit_exceeded', - }, - { status: error.statusCode } - ) - } - - if (error instanceof RateLimitError) { - return NextResponse.json( - { - success: false, - error: error.message, - code: 'rate_limit_exceeded', - }, - { status: error.statusCode } - ) - } - - if (isExecutionConcurrencyLimitError(error)) { - return NextResponse.json( - { - success: false, - error: getExecutionConcurrencyLimitMessage(error), - code: 'execution_concurrency_limit_exceeded', - }, - { status: error.statusCode } - ) - } - - if (isExecutionConcurrencyBackendUnavailableError(error)) { - return NextResponse.json( - { - success: false, - error: error.message, - code: 'execution_limiter_unavailable', - }, - { status: error.statusCode } - ) - } - - if (isLocalVmSaturationLimitError(error)) { - return NextResponse.json( - { - success: false, - error: getLocalVmSaturationLimitMessage(error), - code: 'engine_capacity_exceeded', - }, - { status: error.statusCode } - ) - } - - logger.error(`[${requestId}] Indicator execute failed`, { error }) - return NextResponse.json( - { success: false, error: 'Failed to execute indicators' }, - { status: 500 } - ) - } -} diff --git a/apps/tradinggoose/app/api/market/api-keys/shared.ts b/apps/tradinggoose/app/api/market/api-keys/shared.ts index 958deca80..946d34cd4 100644 --- a/apps/tradinggoose/app/api/market/api-keys/shared.ts +++ b/apps/tradinggoose/app/api/market/api-keys/shared.ts @@ -1,21 +1,16 @@ -import { MARKET_API_URL_DEFAULT, MARKET_API_VERSION } from '@/lib/market/client/constants' -import { resolveMarketApiServiceConfig } from '@/lib/system-services/runtime' +import { MARKET_API_VERSION } from '@/lib/market/client/constants' +import { requestTradingGooseMarket } from '@/lib/market/request-gate' type RemoteServiceKey = { id: string apiKey: string } -async function createRequestInit(body?: Record): Promise { - const marketApi = await resolveMarketApiServiceConfig() +function createRequestInit(body?: Record): RequestInit { const headers: Record = { 'Content-Type': 'application/json', } - if (marketApi.apiKey) { - headers['x-api-key'] = marketApi.apiKey - } - return { method: 'POST', headers, @@ -23,13 +18,8 @@ async function createRequestInit(body?: Record): Promise) { - return fetch(await getMarketApiUrl(endpoint), await createRequestInit(body)) + return requestTradingGooseMarket(endpoint, createRequestInit(body)) } export function maskServiceKeys(apiKeys: RemoteServiceKey[]) { diff --git a/apps/tradinggoose/app/api/market/proxy.test.ts b/apps/tradinggoose/app/api/market/proxy.test.ts index 54efe6ca6..6d9bbf6f0 100644 --- a/apps/tradinggoose/app/api/market/proxy.test.ts +++ b/apps/tradinggoose/app/api/market/proxy.test.ts @@ -37,7 +37,7 @@ vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => mockLogger), })) -describe('market proxy search cache', () => { +describe('market proxy TradingGoose-Market gate', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() @@ -51,8 +51,8 @@ describe('market proxy search cache', () => { it('returns cached search responses before hitting the market service', async () => { mockReadServerJsonCache.mockResolvedValue({ body: '{"data":[{"listing_id":"AAPL"}]}', + headers: [['content-type', 'application/json']], status: 200, - contentType: 'application/json', }) const { proxyMarketRequest } = await import('./proxy') @@ -72,8 +72,8 @@ describe('market proxy search cache', () => { it('uses a global cache key that does not vary by caller headers', async () => { mockReadServerJsonCache.mockResolvedValue({ body: '{"data":[]}', + headers: [['content-type', 'application/json']], status: 200, - contentType: 'application/json', }) const { proxyMarketRequest } = await import('./proxy') @@ -134,9 +134,70 @@ describe('market proxy search cache', () => { expect(mockWriteServerJsonCache).toHaveBeenCalledTimes(1) expect(mockWriteServerJsonCache.mock.calls[0]?.[1]).toEqual({ body: '{"data":[]}', + headers: [['content-type', 'application/json']], status: 200, - contentType: 'application/json', }) expect(mockWriteServerJsonCache.mock.calls[0]?.[2]).toBe(300) }) + + it('stores successful get responses in the shared server JSON cache', async () => { + mockReadServerJsonCache.mockResolvedValue(null) + fetchMock.mockResolvedValue( + new Response('{"data":{"listing_id":"TG_LSTG_AAPL"}}', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) + ) + + const { proxyMarketRequest } = await import('./proxy') + const response = await proxyMarketRequest( + new NextRequest('http://localhost/api/market/get/listing?listing_id=TG_LSTG_AAPL'), + ['get', 'listing'] + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { listing_id: 'TG_LSTG_AAPL' } }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + 'https://market.example.com/api/get/listing?listing_id=TG_LSTG_AAPL&version=v1', + expect.objectContaining({ + method: 'GET', + }) + ) + expect(mockWriteServerJsonCache).toHaveBeenCalledTimes(1) + }) + + it('does not read or write cache for update requests', async () => { + mockReadServerJsonCache.mockResolvedValue({ + body: '{"cached":true}', + headers: [['content-type', 'application/json']], + status: 200, + }) + fetchMock.mockResolvedValue( + new Response('{"success":true}', { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) + ) + + const { proxyMarketRequest } = await import('./proxy') + const response = await proxyMarketRequest( + new NextRequest('http://localhost/api/market/update/listing-rank?listing_id=TG_LSTG_AAPL', { + body: '{}', + method: 'POST', + }), + ['update', 'listing-rank'], + new URLSearchParams({ listing_id: 'TG_LSTG_AAPL' }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mockReadServerJsonCache).not.toHaveBeenCalled() + expect(mockWriteServerJsonCache).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/tradinggoose/app/api/market/proxy.ts b/apps/tradinggoose/app/api/market/proxy.ts index f432b2cc7..03e1c5ff5 100644 --- a/apps/tradinggoose/app/api/market/proxy.ts +++ b/apps/tradinggoose/app/api/market/proxy.ts @@ -1,20 +1,10 @@ -import { createHash } from 'crypto' import { type NextRequest, NextResponse } from 'next/server' -import { readServerJsonCache, writeServerJsonCache } from '@/lib/cache/server-json-cache' import { createLogger } from '@/lib/logs/console/logger' -import { MARKET_API_URL_DEFAULT, MARKET_API_VERSION } from '@/lib/market/client/constants' -import { resolveMarketApiServiceConfig } from '@/lib/system-services/runtime' +import { MARKET_API_VERSION } from '@/lib/market/client/constants' +import { requestTradingGooseMarket } from '@/lib/market/request-gate' import { generateRequestId } from '@/lib/utils' const logger = createLogger('MarketProxyAPI') -const MARKET_SEARCH_CACHE_PREFIX = 'market:search:' -const MARKET_SEARCH_CACHE_TTL_SECONDS = 60 * 5 - -type CachedSearchResponse = { - body: string - contentType: string | null - status: number -} const hopByHopHeaders = new Set([ 'connection', @@ -49,51 +39,58 @@ const resolveVersion = (body: unknown, params?: URLSearchParams | null) => { return normalizeVersion(raw || MARKET_API_VERSION) } -const buildSearchCacheKey = (targetUrl: string) => - `${MARKET_SEARCH_CACHE_PREFIX}${createHash('sha256').update(targetUrl).digest('hex')}` - -const buildSearchCacheTarget = ( - pathSegments: string[] | undefined, - params: URLSearchParams -) => { +const buildMarketEndpoint = (pathSegments?: string[], overrideSearchParams?: URLSearchParams) => { const path = pathSegments?.length ? `/${pathSegments.join('/')}` : '' - const search = params.toString() + const search = overrideSearchParams?.toString() return `/api${path}${search ? `?${search}` : ''}` } -const buildTargetUrl = ( - marketApiUrl: string, +const buildMarketEndpointFromRequest = ( request: NextRequest, pathSegments?: string[], overrideSearchParams?: URLSearchParams ) => { const path = pathSegments?.length ? `/${pathSegments.join('/')}` : '' - const target = new URL(`/api${path}`, marketApiUrl) + const endpoint = new URL(`/api${path}`, 'https://local.tradinggoose') if (overrideSearchParams) { const search = overrideSearchParams.toString() - target.search = search ? `?${search}` : '' + endpoint.search = search ? `?${search}` : '' } else { - target.search = request.nextUrl.search ?? '' + endpoint.search = request.nextUrl.search ?? '' } - return target.toString() + return `${endpoint.pathname}${endpoint.search}` } -const buildForwardHeaders = (request: NextRequest, apiKey: string | null) => { +const buildForwardHeaders = (request: NextRequest) => { const headers = new Headers() request.headers.forEach((value, key) => { if (!hopByHopHeaders.has(key.toLowerCase())) { headers.set(key, value) } }) - if (apiKey) { - headers.set('x-api-key', apiKey) - } if (!headers.get('content-type')) { headers.set('content-type', 'application/json') } return headers } +const buildProxyResponse = async (response: Response) => { + const responseHeaders = new Headers() + response.headers.forEach((value, key) => { + if (!hopByHopHeaders.has(key.toLowerCase())) { + responseHeaders.set(key, value) + } + }) + + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + + return new NextResponse(await response.text(), { + status: response.status, + headers: responseHeaders, + }) +} + export const proxyMarketRequest = async ( request: NextRequest, pathSegments?: string[], @@ -138,6 +135,8 @@ export const proxyMarketRequest = async ( ) } + const headers = buildForwardHeaders(request) + if (method === 'GET') { const targetSearchParams = new URLSearchParams( (overrideSearchParams ?? request.nextUrl.searchParams).toString() @@ -145,87 +144,26 @@ export const proxyMarketRequest = async ( if (!targetSearchParams.get('version')) { targetSearchParams.set('version', version) } - if (isSearch) { - const cacheKey = buildSearchCacheKey(buildSearchCacheTarget(pathSegments, targetSearchParams)) - const cached = await readServerJsonCache(cacheKey) - if (cached) { - return new NextResponse(cached.body, { - status: cached.status, - headers: cached.contentType - ? { 'content-type': cached.contentType } - : undefined, - }) + const response = await requestTradingGooseMarket( + buildMarketEndpoint(pathSegments, targetSearchParams), + { + method, + headers, } - } - const marketApi = await resolveMarketApiServiceConfig() - const targetUrl = buildTargetUrl( - marketApi.baseUrl || MARKET_API_URL_DEFAULT, - request, - pathSegments, - targetSearchParams ) - const headers = buildForwardHeaders(request, marketApi.apiKey) - const response = await fetch(targetUrl, { - method, - headers, - }) - - const responseHeaders = new Headers() - response.headers.forEach((value, key) => { - if (!hopByHopHeaders.has(key.toLowerCase())) { - responseHeaders.set(key, value) - } - }) - - responseHeaders.delete('content-encoding') - responseHeaders.delete('content-length') - - const responseBody = await response.text() - - if (isSearch && response.ok) { - await writeServerJsonCache(buildSearchCacheKey(buildSearchCacheTarget(pathSegments, targetSearchParams)), { - body: responseBody, - status: response.status, - contentType: responseHeaders.get('content-type'), - }, MARKET_SEARCH_CACHE_TTL_SECONDS) - } - - return new NextResponse(responseBody, { - status: response.status, - headers: responseHeaders, - }) + return buildProxyResponse(response) } - const marketApi = await resolveMarketApiServiceConfig() - const targetUrl = buildTargetUrl( - marketApi.baseUrl || MARKET_API_URL_DEFAULT, - request, - pathSegments, - overrideSearchParams - ) - const headers = buildForwardHeaders(request, marketApi.apiKey) const forwardBody = JSON.stringify({ ...bodyPayload, version }) - const response = await fetch(targetUrl, { - method, - headers, - body: forwardBody, - }) - - const responseHeaders = new Headers() - response.headers.forEach((value, key) => { - if (!hopByHopHeaders.has(key.toLowerCase())) { - responseHeaders.set(key, value) + const response = await requestTradingGooseMarket( + buildMarketEndpointFromRequest(request, pathSegments, overrideSearchParams), + { + method, + headers, + body: forwardBody, } - }) - - // Avoid content decoding mismatches when proxying compressed responses. - responseHeaders.delete('content-encoding') - responseHeaders.delete('content-length') - - return new NextResponse(response.body, { - status: response.status, - headers: responseHeaders, - }) + ) + return buildProxyResponse(response) } catch (error) { logger.error(`[${requestId}] Market proxy failed`, { error: error instanceof Error ? error.message : String(error), diff --git a/apps/tradinggoose/app/api/market/search/validation.ts b/apps/tradinggoose/app/api/market/search/validation.ts index 27fabb76c..fb0201a08 100644 --- a/apps/tradinggoose/app/api/market/search/validation.ts +++ b/apps/tradinggoose/app/api/market/search/validation.ts @@ -20,7 +20,7 @@ export const buildQueryParams = (request: NextRequest, keys: string[]) => { return params } -export const uniqueNonEmpty = (values: Array) => { +export const dedupeNonEmptyStrings = (values: Array) => { const seen = new Set() const result: string[] = [] for (const value of values) { @@ -32,10 +32,7 @@ export const uniqueNonEmpty = (values: Array) => { } export const parseListParam = (searchParams: URLSearchParams, key: string) => { - const rawValues = [ - ...searchParams.getAll(key), - ...searchParams.getAll(`${key}[]`), - ] + const rawValues = [...searchParams.getAll(key), ...searchParams.getAll(`${key}[]`)] if (!rawValues.length) return [] const tokens: string[] = [] @@ -77,5 +74,5 @@ export const parseListParam = (searchParams: URLSearchParams, key: string) => { pushToken(trimmed) } - return uniqueNonEmpty(tokens) + return dedupeNonEmptyStrings(tokens) } diff --git a/apps/tradinggoose/app/api/providers/trading/order/route.test.ts b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts new file mode 100644 index 000000000..ec1240ddb --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts @@ -0,0 +1,581 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@/app/api/__test-utils__/utils' + +const mockGetSession = vi.fn() +const mockGetOAuthToken = vi.fn() +const mockListTradingAccounts = vi.fn() +const mockFetch = vi.fn() + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getOAuthToken: mockGetOAuthToken, +})) + +vi.mock('@/providers/trading/portfolio', async () => { + const actual = await vi.importActual('@/providers/trading/portfolio') + return { + ...(actual as object), + listTradingAccounts: mockListTradingAccounts, + } +}) + +const stockListing = { + listing_type: 'default', + listing_id: 'AAPL', + base: 'AAPL', + quote: 'USD', + assetClass: 'stock', +} + +const etfListing = { + listing_type: 'default', + listing_id: 'SPY', + base: 'SPY', + quote: 'USD', + assetClass: 'etf', +} + +describe('Trading provider order route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetOAuthToken.mockResolvedValue('access-token') + mockListTradingAccounts.mockResolvedValue([ + { id: 'ACC-1', name: 'Main', type: 'cash', baseCurrency: 'USD', status: 'active' }, + ]) + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + order: { + id: 'order-1', + status: 'submitted', + symbol: 'AAPL', + side: 'buy', + create_date: '2026-04-25T12:00:00.000Z', + message: 'Order accepted', + }, + }), + { status: 200 } + ) + ) + }) + + it('rejects invalid JSON before auth or broker calls', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + new Request('http://localhost:3000/api/providers/trading/order', { + method: 'POST', + body: '{', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects invalid sides and numeric strings before auth or broker calls', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const invalidSideResponse = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'hold', + quantity: 1, + }) + ) + const numericStringResponse = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: '1', + }) + ) + + expect(invalidSideResponse.status).toBe(400) + await expect(invalidSideResponse.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(numericStringResponse.status).toBe(400) + await expect(numericStringResponse.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects listings without resolved asset class before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: { listing_type: 'default', listing_id: 'AAPL', base: 'AAPL' }, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'Resolved listing asset class is required', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects raw string listings before auth or broker calls', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: 'AAPL', + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each(['providerParams', 'class', 'tag', 'preview', 'optionSymbol', 'option_symbol', 'legs'])( + 'rejects unsupported top-level quick order extras: %s', + async (field) => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + [field]: field === 'legs' ? [] : 'advanced', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it('rejects unsupported listing asset classes before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: etfListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Unsupported listing for provider' }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects unsupported order types before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + orderType: 'trailing_stop', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Unsupported order type' }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects Alpaca notional trailing stop orders before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + orderSizingMode: 'notional', + notional: 100, + orderType: 'trailing_stop', + timeInForce: 'day', + trailPrice: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'Alpaca notional orders support market, limit, stop, or stop_limit types.', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects no supported order types before account discovery', async () => { + vi.resetModules() + vi.doMock('@/providers/trading/order-types', async () => { + const actual = await vi.importActual( + '@/providers/trading/order-types' + ) + return { + ...actual, + getStrictTradingOrderTypeDefinitions: vi.fn(() => []), + } + }) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'No supported order types for listing', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + + vi.doUnmock('@/providers/trading/order-types') + vi.resetModules() + }) + + it('rejects missing provider connections before account discovery', async () => { + mockGetOAuthToken.mockResolvedValue(null) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + error: 'Trading provider connection not found', + }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('requires accountId before account discovery', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error: 'Invalid request data' }) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects accounts that do not belong to the provider connection', async () => { + mockListTradingAccounts.mockResolvedValue([{ id: 'ACC-2', type: 'cash', baseCurrency: 'USD' }]) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ + error: 'Account not found for provider connection', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [ + { + trailPrice: 1, + trailPercent: 1, + }, + 'Enter either trail price or trail percent.', + ], + [{}, 'Enter either trail price or trail percent.'], + [ + { + trailPrice: 1, + limitPrice: 100, + }, + 'Alpaca trailing stop orders do not accept limitPrice or stopPrice', + ], + ])( + 'rejects invalid Alpaca trailing stop payloads before account discovery', + async (fields, error) => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'sell', + quantity: 1, + orderType: 'trailing_stop', + ...fields, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ error }) + expect(mockListTradingAccounts).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it('submits valid Alpaca quantity orders without using order history', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'alpaca-order-1', + status: 'accepted', + symbol: 'AAPL', + side: 'buy', + submitted_at: '2026-04-25T12:00:00.000Z', + }), + { status: 200 } + ) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 3, + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + provider: 'alpaca', + accountId: 'ACC-1', + order: { + id: 'alpaca-order-1', + status: 'accepted', + symbol: 'AAPL', + side: 'buy', + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.alpaca.markets/v2/orders') + expect(url).not.toContain('/api/tools/trading/order-history') + expect(JSON.parse(String(init.body))).toMatchObject({ + symbol: 'AAPL', + side: 'buy', + type: 'market', + time_in_force: 'day', + qty: '3', + }) + }) + + it('submits valid Alpaca notional orders without sending quantity', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'alpaca-order-2', status: 'accepted' }), { + status: 200, + }) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'alpaca', + credentialServiceId: 'alpaca-live', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + orderSizingMode: 'notional', + notional: 100.5, + timeInForce: 'day', + }) + ) + + expect(response.status).toBe(200) + expect(mockFetch).toHaveBeenCalledTimes(1) + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(init.body))).toMatchObject({ + symbol: 'AAPL', + side: 'buy', + type: 'market', + time_in_force: 'day', + notional: 100.5, + }) + expect(JSON.parse(String(init.body))).not.toHaveProperty('qty') + }) + + it('submits valid Tradier equity quantity orders without using order history', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 3, + limitPrice: 100, + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + provider: 'tradier', + accountId: 'ACC-1', + message: 'Order accepted', + order: { + id: 'order-1', + status: 'submitted', + symbol: 'AAPL', + side: 'buy', + }, + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/accounts/ACC-1/orders') + expect(url).not.toContain('/api/tools/trading/order-history') + expect(String(init.body)).toContain('class=equity') + expect(String(init.body)).toContain('symbol=AAPL') + expect(String(init.body)).toContain('quantity=3') + expect(String(init.body)).not.toContain('price=') + }) + + it('preserves listing enrichment fields in provider builders', async () => { + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: { + ...stockListing, + listing_id: 'IGNORED', + base: 'TSLA', + marketCode: 'NASDAQ', + countryCode: 'US', + cityName: 'New York', + }, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(200) + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(String(init.body)).toContain('symbol=TSLA') + expect(String(init.body)).not.toContain('IGNORED') + }) + + it('extracts broker message-like fields into the quick order response', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + order: { + id: 'order-2', + status: 'rejected', + symbol: 'AAPL', + reject_reason: 'Insufficient buying power', + }, + }), + { status: 200 } + ) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + message: 'Insufficient buying power', + }) + }) + + it('maps broker fetch failures to 502 without persisting', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Broker unavailable' }), { status: 500 }) + ) + + const { POST } = await import('@/app/api/providers/trading/order/route') + const response = await POST( + createMockRequest('POST', { + provider: 'tradier', + accountId: 'ACC-1', + listing: stockListing, + side: 'buy', + quantity: 1, + }) + ) + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ error: 'Broker request failed' }) + }) +}) diff --git a/apps/tradinggoose/app/api/providers/trading/order/route.ts b/apps/tradinggoose/app/api/providers/trading/order/route.ts new file mode 100644 index 000000000..c3b56abfd --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/order/route.ts @@ -0,0 +1,383 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import type { ListingInputValue } from '@/lib/listing/identity' +import { toListingValueObject } from '@/lib/listing/identity' +import type { QuickOrderSubmitResponse } from '@/app/api/providers/trading/order/types' +import { + createTradingProviderRequestId, + logBrokerRequestFailure, + resolveTradingProviderContext, + resolveTradingProviderPreflight, + resolveTradingProviderSelectedAccount, +} from '@/app/api/providers/trading/shared' +import { executeTradingProviderRequest, getTradingProvider } from '@/providers/trading' +import { getStrictTradingOrderTypeDefinitions } from '@/providers/trading/order-types' +import { + ALPACA_TRAILING_STOP_TRAIL_VALUE_ERROR, + getAlpacaNotionalOrderTypeError, +} from '@/providers/trading/order-validation' +import { fetchBrokerJson, TradingBrokerRequestError } from '@/providers/trading/portfolio-utils' +import { getTradingProviderConfig } from '@/providers/trading/providers' +import type { TradingOrder, TradingOrderRequest, TradingOrderType } from '@/providers/trading/types' +import { + isTradingOrderListingSupported, + resolveTradingListingAssetClass, +} from '@/providers/trading/utils' + +const positiveNumberSchema = z.number().positive().finite() +const nonEmptyStringSchema = z.string().trim().min(1) + +const orderListingSchema = z + .object({ + listing_type: z.enum(['default', 'crypto', 'currency']), + listing_id: z.string().optional(), + base_id: z.string().optional(), + quote_id: z.string().optional(), + }) + .passthrough() + +const orderSchema = z + .object({ + provider: nonEmptyStringSchema, + credentialServiceId: nonEmptyStringSchema.optional(), + accountId: nonEmptyStringSchema, + listing: orderListingSchema, + side: z.enum(['buy', 'sell']), + quantity: positiveNumberSchema.optional(), + notional: positiveNumberSchema.optional(), + orderSizingMode: z.enum(['quantity', 'notional']).optional(), + orderType: nonEmptyStringSchema.optional(), + timeInForce: nonEmptyStringSchema.optional(), + limitPrice: positiveNumberSchema.optional(), + stopPrice: positiveNumberSchema.optional(), + trailPrice: positiveNumberSchema.optional(), + trailPercent: positiveNumberSchema.optional(), + }) + .strict() + +type OrderRequestData = z.infer + +const errorResponse = (error: string, status = 400) => NextResponse.json({ error }, { status }) + +const hasNumber = (value: number | undefined): value is number => + typeof value === 'number' && Number.isFinite(value) + +const getTimeInForceOptions = (providerId: string) => + getTradingProviderConfig(providerId)?.capabilities?.order?.timeInForce ?? [] + +const resolveTimeInForce = ( + providerId: string, + requested: string | undefined +): string | NextResponse => { + const timeInForceOptions = getTimeInForceOptions(providerId) + const requestedTimeInForce = requested?.trim() + + if (requestedTimeInForce) { + if (!timeInForceOptions.includes(requestedTimeInForce)) { + return errorResponse('Unsupported timeInForce for provider') + } + return requestedTimeInForce + } + + const provider = getTradingProvider(providerId) + const fallback = provider.defaults?.timeInForce ?? timeInForceOptions[0] + return fallback || errorResponse('timeInForce is required') +} + +const resolveOrderType = ( + providerId: string, + data: OrderRequestData +): { orderType: TradingOrderType; requires: string[] } | NextResponse => { + const context = { + listing: data.listing as ListingInputValue, + orderClass: providerId === 'tradier' ? 'equity' : undefined, + } + const strictDefinitions = getStrictTradingOrderTypeDefinitions(providerId, context) + if (!strictDefinitions.length) { + return errorResponse('No supported order types for listing') + } + + const requestedOrderType = data.orderType?.trim() + if (requestedOrderType) { + const requestedDefinition = strictDefinitions.find( + (definition) => definition.id === requestedOrderType + ) + if (!requestedDefinition) { + return errorResponse('Unsupported order type') + } + return { + orderType: requestedDefinition.id as TradingOrderType, + requires: requestedDefinition.requires ?? [], + } + } + + const provider = getTradingProvider(providerId) + const defaultDefinition = + strictDefinitions.find((definition) => definition.id === provider.defaults?.orderType) ?? + strictDefinitions[0] + + return { + orderType: defaultDefinition.id as TradingOrderType, + requires: defaultDefinition.requires ?? [], + } +} + +const validateRequiredNumber = ( + data: OrderRequestData, + field: 'limitPrice' | 'stopPrice' | 'trailPrice' | 'trailPercent' +): NextResponse | null => { + return hasNumber(data[field]) ? null : errorResponse(`${field} is required`) +} + +const validateAlpacaSizing = ( + data: OrderRequestData, + orderType: TradingOrderType, + timeInForce: string +): NextResponse | null => { + const sizingMode = data.orderSizingMode ?? 'quantity' + if (sizingMode === 'notional') { + if (!hasNumber(data.notional)) return errorResponse('notional is required') + const orderTypeError = getAlpacaNotionalOrderTypeError(orderType) + if (orderTypeError) return errorResponse(orderTypeError) + if (timeInForce !== 'day') { + return errorResponse('Alpaca notional orders require timeInForce=day') + } + return null + } + + return hasNumber(data.quantity) ? null : errorResponse('quantity is required') +} + +const validateTradierSizing = (data: OrderRequestData): NextResponse | null => { + if (data.orderSizingMode || hasNumber(data.notional)) { + return errorResponse('Notional sizing is only supported for Alpaca') + } + return hasNumber(data.quantity) ? null : errorResponse('quantity is required') +} + +const validateOrderFields = ( + providerId: string, + data: OrderRequestData, + orderType: TradingOrderType, + requires: string[], + timeInForce: string +): NextResponse | null => { + const sizingError = + providerId === 'alpaca' + ? validateAlpacaSizing(data, orderType, timeInForce) + : validateTradierSizing(data) + if (sizingError) return sizingError + + if (providerId === 'alpaca' && orderType === 'trailing_stop') { + const hasTrailPrice = hasNumber(data.trailPrice) + const hasTrailPercent = hasNumber(data.trailPercent) + if (hasNumber(data.limitPrice) || hasNumber(data.stopPrice)) { + return errorResponse('Alpaca trailing stop orders do not accept limitPrice or stopPrice') + } + if (hasTrailPrice === hasTrailPercent) { + return errorResponse(ALPACA_TRAILING_STOP_TRAIL_VALUE_ERROR) + } + return null + } + + for (const field of requires) { + if ( + field === 'limitPrice' || + field === 'stopPrice' || + field === 'trailPrice' || + field === 'trailPercent' + ) { + const fieldError = validateRequiredNumber(data, field) + if (fieldError) return fieldError + } + } + + return null +} + +const buildOrderRequest = ( + providerId: string, + data: OrderRequestData, + context: { + accessToken: string + accountId: string + environment: 'paper' | 'live' + }, + orderType: TradingOrderType, + timeInForce: string +): TradingOrderRequest => { + const usesLimitPrice = orderType === 'limit' || orderType === 'stop_limit' + const usesStopPrice = orderType === 'stop' || orderType === 'stop_limit' + const usesTrailValue = orderType === 'trailing_stop' + const request: TradingOrderRequest = { + kind: 'order', + listing: data.listing as ListingInputValue, + assetClass: resolveTradingListingAssetClass(data.listing as ListingInputValue), + side: data.side, + orderType, + timeInForce, + quantity: data.quantity, + limitPrice: usesLimitPrice ? data.limitPrice : undefined, + stopPrice: usesStopPrice ? data.stopPrice : undefined, + trailPrice: usesTrailValue ? data.trailPrice : undefined, + trailPercent: usesTrailValue ? data.trailPercent : undefined, + environment: context.environment, + accessToken: context.accessToken, + accountId: context.accountId, + } + + if (providerId === 'alpaca') { + request.orderSizingMode = data.orderSizingMode ?? 'quantity' + if (request.orderSizingMode === 'notional') { + request.quantity = undefined + request.notional = data.notional + } + } + + if (providerId === 'tradier') { + request.providerParams = { orderClass: 'equity' } + } + + return request +} + +const toFetchBody = (body: string | Record | undefined) => { + if (typeof body === 'string' || body === undefined) return body + return JSON.stringify(body) +} + +const MESSAGE_KEYS = ['message', 'status_message', 'reason', 'reject_reason', 'error'] as const + +const readMessage = (value: unknown, seen = new WeakSet()): string | null => { + if (typeof value === 'string' && value.trim()) return value.trim() + if (!value || typeof value !== 'object') return null + if (seen.has(value)) return null + seen.add(value) + + const record = value as Record + + for (const key of MESSAGE_KEYS) { + const message = record[key] + if (typeof message === 'string' && message.trim()) return message.trim() + } + + const errors = record.errors + if (Array.isArray(errors)) { + for (const error of errors) { + const nested = readMessage(error, seen) + if (nested) return nested + } + } else { + const nested = readMessage(errors, seen) + if (nested) return nested + } + + for (const key of ['order', 'raw'] as const) { + const nested = readMessage(record[key], seen) + if (nested) return nested + } + + return null +} + +const extractOrderProviderMessage = ( + rawOrder: unknown, + normalizedOrder: TradingOrder | null +): string | null => + readMessage(rawOrder) ?? readMessage(normalizedOrder?.raw) ?? readMessage(normalizedOrder) + +export async function POST(request: Request) { + const requestId = createTradingProviderRequestId('order') + const requestData = await resolveTradingProviderPreflight({ + request, + schema: orderSchema, + }) + if (requestData instanceof Response) return requestData + + const baseContext = await resolveTradingProviderContext({ + requestData, + requestId, + }) + if (baseContext instanceof Response) return baseContext + + const resolvedListingForRequest = requestData.listing as ListingInputValue + const listingIdentity = toListingValueObject(resolvedListingForRequest) + if (!listingIdentity) { + return errorResponse('Resolved listing is required') + } + + const assetClass = resolveTradingListingAssetClass(resolvedListingForRequest) + if (!assetClass) { + return errorResponse('Resolved listing asset class is required') + } + + if (!isTradingOrderListingSupported(baseContext.providerId, resolvedListingForRequest)) { + return errorResponse('Unsupported listing for provider') + } + + const orderTypeResult = resolveOrderType(baseContext.providerId, requestData) + if (orderTypeResult instanceof Response) return orderTypeResult + + const timeInForce = resolveTimeInForce(baseContext.providerId, requestData.timeInForce) + if (timeInForce instanceof Response) return timeInForce + + const fieldError = validateOrderFields( + baseContext.providerId, + requestData, + orderTypeResult.orderType, + orderTypeResult.requires, + timeInForce + ) + if (fieldError) return fieldError + + const accountContext = await resolveTradingProviderSelectedAccount({ + baseContext, + accountId: requestData.accountId, + }) + if (accountContext instanceof Response) return accountContext + + try { + const provider = getTradingProvider(baseContext.providerId) + const providerRequest = executeTradingProviderRequest( + baseContext.providerId, + buildOrderRequest( + baseContext.providerId, + requestData, + { + accessToken: baseContext.accessToken, + accountId: accountContext.accountId, + environment: baseContext.environment, + }, + orderTypeResult.orderType, + timeInForce + ) + ) + + const rawOrder = await fetchBrokerJson({ + providerId: baseContext.providerId, + url: providerRequest.url, + init: { + method: providerRequest.method, + headers: providerRequest.headers, + body: toFetchBody(providerRequest.body), + }, + }) + + const order = provider.normalizeOrder?.(rawOrder) ?? null + + const response: QuickOrderSubmitResponse = { + order, + provider: baseContext.providerId, + accountId: accountContext.accountId, + message: extractOrderProviderMessage(rawOrder, order), + } + + return NextResponse.json(response) + } catch (error) { + logBrokerRequestFailure('order', error) + if (error instanceof TradingBrokerRequestError) { + return errorResponse('Broker request failed', 502) + } + return errorResponse(error instanceof Error ? error.message : 'Order submission failed') + } +} diff --git a/apps/tradinggoose/app/api/providers/trading/order/types.ts b/apps/tradinggoose/app/api/providers/trading/order/types.ts new file mode 100644 index 000000000..d089a0093 --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/order/types.ts @@ -0,0 +1,30 @@ +import type { ListingIdentity, ListingResolved } from '@/lib/listing/identity' +import type { TradingOrder } from '@/providers/trading/types' + +export type QuickOrderResolvedListing = + | ListingResolved + | (ListingIdentity & Record) + +export interface QuickOrderSubmitRequest { + provider: string + credentialServiceId?: string + accountId: string + listing: QuickOrderResolvedListing + side: 'buy' | 'sell' + quantity?: number + notional?: number + orderSizingMode?: 'quantity' | 'notional' + orderType?: string + timeInForce?: string + limitPrice?: number + stopPrice?: number + trailPrice?: number + trailPercent?: number +} + +export interface QuickOrderSubmitResponse { + order: TradingOrder | null + provider: string + accountId: string + message?: string | null +} diff --git a/apps/tradinggoose/app/api/providers/trading/shared.ts b/apps/tradinggoose/app/api/providers/trading/shared.ts new file mode 100644 index 000000000..e44d2e17a --- /dev/null +++ b/apps/tradinggoose/app/api/providers/trading/shared.ts @@ -0,0 +1,175 @@ +import { NextResponse } from 'next/server' +import type { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { getOAuthToken } from '@/app/api/auth/oauth/utils' +import { listTradingAccounts } from '@/providers/trading/portfolio' +import { TradingBrokerRequestError } from '@/providers/trading/portfolio-utils' +import { + getTradingProviderDefinition, + getTradingProviderOAuthEnvironment, + getTradingProviderOAuthServiceId, +} from '@/providers/trading/providers' +import type { UnifiedTradingAccount } from '@/providers/trading/types' + +const logger = createLogger('TradingProviderRoutes') + +type ProviderRequestData = { + provider?: string + credentialServiceId?: string +} + +type PreflightContext = { + requestId: string + providerId: string + environment: 'paper' | 'live' + accessToken: string + sessionUserId: string +} + +export type TradingProviderBaseRouteContext = PreflightContext + +export type TradingAccountRouteContext = PreflightContext & { + accountId: string + account: UnifiedTradingAccount +} + +const parseRequestBody = async ( + request: Request, + schema: z.ZodSchema +): Promise => { + let body: unknown + + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid request data' }, { status: 400 }) + } + + const parsed = schema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request data' }, { status: 400 }) + } + + return parsed.data +} + +const requireStringField = ( + data: Record, + field: string +): string | NextResponse => { + const value = data[field]?.trim() + if (!value) { + return NextResponse.json({ error: `${field} is required` }, { status: 400 }) + } + return value +} + +export async function resolveTradingProviderPreflight({ + request, + schema, +}: { + request: Request + schema: z.ZodSchema +}): Promise { + return parseRequestBody(request, schema) +} + +export async function resolveTradingProviderContext({ + requestData, + requestId, +}: { + requestData: ProviderRequestData + requestId: string +}): Promise { + const providerId = requireStringField(requestData, 'provider') + if (providerId instanceof NextResponse) return providerId + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const providerDefinition = getTradingProviderDefinition(providerId) + if (!providerDefinition) { + return NextResponse.json({ error: 'Unsupported provider' }, { status: 400 }) + } + + const serviceId = getTradingProviderOAuthServiceId(providerId, requestData.credentialServiceId) + if (!serviceId) { + return NextResponse.json({ error: 'Trading provider connection is required' }, { status: 400 }) + } + + const accessToken = await getOAuthToken(session.user.id, serviceId) + if (!accessToken) { + return NextResponse.json({ error: 'Trading provider connection not found' }, { status: 404 }) + } + const environment = getTradingProviderOAuthEnvironment(providerId, serviceId) + if (!environment) { + return NextResponse.json( + { error: 'Trading provider connection is not configured' }, + { status: 400 } + ) + } + + return { + requestId, + providerId, + environment, + accessToken, + sessionUserId: session.user.id, + } +} + +export async function resolveTradingProviderSelectedAccount({ + baseContext, + accountId, +}: { + baseContext: TradingProviderBaseRouteContext + accountId?: string +}): Promise { + const selectedAccountId = requireStringField({ accountId }, 'accountId') + if (selectedAccountId instanceof NextResponse) return selectedAccountId + + const accounts = await listTradingAccounts({ + providerId: baseContext.providerId, + environment: baseContext.environment, + accessToken: baseContext.accessToken, + }) + + const account = accounts.find((candidate) => candidate.id === selectedAccountId) + if (!account) { + return NextResponse.json( + { error: 'Account not found for provider connection' }, + { status: 404 } + ) + } + + return { + ...baseContext, + accountId: selectedAccountId, + account, + } +} + +export const createTradingProviderRequestId = (route: string) => + `${route}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + +export const logBrokerRequestFailure = (route: string, error: unknown) => { + if (error instanceof TradingBrokerRequestError) { + logger.error(`Broker request failed in ${route}`, { + error: error.message, + stack: error.stack, + providerId: error.providerId, + status: error.status, + url: error.url, + payload: error.payload, + }) + return + } + + logger.error(`Broker request failed in ${route}`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) +} diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index e8135fb8c..9ee6d745a 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -4,13 +4,7 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { - loadWorkflowFromNormalizedTables, - type NormalizedWorkflowData, -} from '@/lib/workflows/db-helpers' -import { - resolveAutoLayoutDirection, -} from '@/lib/workflows/workflow-direction' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { getWorkflowAccessContext } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' @@ -18,46 +12,29 @@ export const dynamic = 'force-dynamic' const logger = createLogger('AutoLayoutAPI') const AutoLayoutRequestSchema = z.object({ - strategy: z - .enum(['smart', 'hierarchical', 'layered', 'force-directed']) - .optional() - .default('smart'), - direction: z.enum(['horizontal', 'vertical', 'auto']).optional().default('auto'), spacing: z .object({ - horizontal: z.number().min(100).max(1000).optional().default(400), - vertical: z.number().min(50).max(500).optional().default(200), - layer: z.number().min(200).max(1200).optional().default(600), + horizontal: z.number().min(100).max(1000).optional(), + vertical: z.number().min(50).max(500).optional(), }) - .optional() - .default({}), - alignment: z.enum(['start', 'center', 'end']).optional().default('center'), + .optional(), + alignment: z.enum(['start', 'center', 'end']).optional(), padding: z .object({ - x: z.number().min(50).max(500).optional().default(200), - y: z.number().min(50).max(500).optional().default(200), + x: z.number().min(50).max(500).optional(), + y: z.number().min(50).max(500).optional(), }) - .optional() - .default({}), - // Optional: if provided, use these blocks instead of loading from DB - // This allows using blocks with live measurements from the UI + .optional(), blocks: z.record(z.any()).optional(), edges: z.array(z.any()).optional(), - loops: z.record(z.any()).optional(), - parallels: z.record(z.any()).optional(), }) -/** - * POST /api/workflows/[id]/autolayout - * Apply autolayout to an existing workflow - */ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() const startTime = Date.now() const { id: workflowId } = await params try { - // Get the session const session = await getSession() if (!session?.user?.id) { logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) @@ -66,17 +43,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const userId = session.user.id - // Parse request body const body = await request.json() const layoutOptions = AutoLayoutRequestSchema.parse(body) logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { - strategy: layoutOptions.strategy, - direction: layoutOptions.direction, userId, }) - // Fetch the workflow to check ownership/access const accessContext = await getWorkflowAccessContext(workflowId, userId) const workflowData = accessContext?.workflow @@ -85,7 +58,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - // Check if user has permission to update this workflow const canUpdate = accessContext?.isOwner || (workflowData.workspaceId @@ -100,18 +72,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Use provided blocks/edges if available (with live measurements from UI), - // otherwise load from database - let currentWorkflowData: NormalizedWorkflowData | null + let currentWorkflowData: { blocks: Record; edges: any[] } | null if (layoutOptions.blocks && layoutOptions.edges) { logger.info(`[${requestId}] Using provided blocks with live measurements`) currentWorkflowData = { blocks: layoutOptions.blocks, edges: layoutOptions.edges, - loops: layoutOptions.loops || {}, - parallels: layoutOptions.parallels || {}, - isFromNormalizedTables: false, } } else { logger.info(`[${requestId}] Loading blocks from database`) @@ -124,27 +91,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } const autoLayoutOptions = { - direction: resolveAutoLayoutDirection( - { - blocks: currentWorkflowData.blocks, - edges: currentWorkflowData.edges, - }, - layoutOptions.direction - ), - horizontalSpacing: layoutOptions.spacing?.horizontal || 550, - verticalSpacing: layoutOptions.spacing?.vertical || 200, + horizontalSpacing: layoutOptions.spacing?.horizontal ?? 550, + verticalSpacing: layoutOptions.spacing?.vertical ?? 200, padding: { - x: layoutOptions.padding?.x || 150, - y: layoutOptions.padding?.y || 150, + x: layoutOptions.padding?.x ?? 150, + y: layoutOptions.padding?.y ?? 150, }, - alignment: layoutOptions.alignment, + alignment: layoutOptions.alignment ?? 'center', } const layoutResult = applyAutoLayout( currentWorkflowData.blocks, currentWorkflowData.edges, - currentWorkflowData.loops || {}, - currentWorkflowData.parallels || {}, autoLayoutOptions ) @@ -166,7 +124,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, { blockCount, - strategy: layoutOptions.strategy, workflowId, }) @@ -174,8 +131,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ success: true, message: `Autolayout applied successfully to ${blockCount} blocks`, data: { - strategy: layoutOptions.strategy, - direction: autoLayoutOptions.direction, blockCount, elapsed: `${elapsed}ms`, layoutedBlocks: layoutResult.blocks, diff --git a/apps/tradinggoose/app/api/yaml/autolayout/route.ts b/apps/tradinggoose/app/api/yaml/autolayout/route.ts index 108830cc1..403be9a3f 100644 --- a/apps/tradinggoose/app/api/yaml/autolayout/route.ts +++ b/apps/tradinggoose/app/api/yaml/autolayout/route.ts @@ -3,9 +3,6 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { - resolveAutoLayoutDirection, -} from '@/lib/workflows/workflow-direction' const logger = createLogger('YamlAutoLayoutAPI') @@ -18,13 +15,10 @@ const AutoLayoutRequestSchema = z.object({ }), options: z .object({ - strategy: z.enum(['smart', 'hierarchical', 'layered', 'force-directed']).optional(), - direction: z.enum(['horizontal', 'vertical', 'auto']).optional(), spacing: z .object({ horizontal: z.number().optional(), vertical: z.number().optional(), - layer: z.number().optional(), }) .optional(), alignment: z.enum(['start', 'center', 'end']).optional(), @@ -48,31 +42,21 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Applying auto layout`, { blockCount: Object.keys(workflowState.blocks).length, edgeCount: workflowState.edges.length, - strategy: options?.strategy || 'smart', }) const autoLayoutOptions = { - direction: resolveAutoLayoutDirection( - { - blocks: workflowState.blocks, - edges: workflowState.edges, - }, - options?.direction - ), - horizontalSpacing: options?.spacing?.horizontal || 550, - verticalSpacing: options?.spacing?.vertical || 200, + horizontalSpacing: options?.spacing?.horizontal ?? 550, + verticalSpacing: options?.spacing?.vertical ?? 200, padding: { - x: options?.padding?.x || 150, - y: options?.padding?.y || 150, + x: options?.padding?.x ?? 150, + y: options?.padding?.y ?? 150, }, - alignment: options?.alignment || 'center', + alignment: options?.alignment ?? 'center', } const layoutResult = applyAutoLayout( workflowState.blocks, workflowState.edges, - workflowState.loops || {}, - workflowState.parallels || {}, autoLayoutOptions ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx index db0240283..5f8265caf 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.test.tsx @@ -229,7 +229,74 @@ describe('DashboardClient', () => { consoleError.mockRestore() }) - it('preserves persisted review targets independently from ambient current ids during hydration', async () => { + it('fills missing shared workflow state when switching a gray widget into a partially populated color', async () => { + await act(async () => { + root.render( + + ) + }) + + const switchToRedButton = container.querySelector('[data-testid="pair-color-red-panel-a"]') + if (!(switchToRedButton instanceof HTMLButtonElement)) { + throw new Error('Expected pair color switch button to be rendered') + } + + await act(async () => { + switchToRedButton.click() + }) + + expect(usePairColorStore.getState().contexts.red).toEqual({ + workflowId: 'wf-local', + listing: { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + }, + }) + expect(readWidgetSurface(container, 'panel-a')).toEqual({ + workflowId: '', + workspaceId: 'ws-a', + pairColor: 'red', + }) + }) + + it('ignores persisted review targets during color-pair hydration', async () => { await act(async () => { root.render( { expect(usePairColorStore.getState().contexts.red).toMatchObject({ workflowId: 'wf-current', skillId: 'skill-saved', - reviewTarget: { - reviewSessionId: 'review-draft-skill', - reviewEntityKind: 'skill', - reviewEntityId: null, - reviewDraftSessionId: 'draft-skill', - }, }) }) @@ -410,8 +471,11 @@ function createLayouts(layoutId: string): LayoutTab[] { ] } -function readWidgetSurface(container: HTMLDivElement) { - const element = container.querySelector('[data-testid^="widget-surface-"]') +function readWidgetSurface(container: HTMLDivElement, panelId?: string) { + const selector = panelId + ? `[data-testid="widget-surface-${panelId}"]` + : '[data-testid^="widget-surface-"]' + const element = container.querySelector(selector) if (!(element instanceof HTMLElement)) { throw new Error('Expected widget surface to be rendered') } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx index 1810432a6..840b046cb 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx @@ -442,11 +442,13 @@ export function DashboardClient({ const handlePairColorChange = useCallback((panelId: string, color: PairColor) => { const currentTree = latestLayoutRef.current - const previousColor = findPanelPairColor(currentTree, panelId) + const currentWidget = findPanelWidget(currentTree, panelId) + const previousColor = isPairColor(currentWidget?.pairColor) ? currentWidget.pairColor : undefined if (previousColor === color) { return } + seedPairContextForColorSwitch(previousColor, color, currentWidget) const nextTree = updatePanelPairColor(currentTree, panelId, color) if (nextTree === currentTree) { return @@ -454,7 +456,6 @@ export function DashboardClient({ latestLayoutRef.current = nextTree setTree(nextTree) - clonePairContextIfEmpty(previousColor, color) }, []) const searchKnowledgeBases = useMemo( @@ -1169,9 +1170,7 @@ function applyPairDataToWidget( 'reviewDraftSessionId', ] as const - const hasPairData = - pairKeys.some((k) => pairData[k] != null) || - reviewKeys.some((k) => pairData.reviewTarget?.[k] != null) + const hasPairData = pairKeys.some((k) => pairData[k] != null) const hasPairParams = pairKeys.some((k) => k in baseParams) || reviewKeys.some((k) => k in baseParams) || @@ -1185,7 +1184,7 @@ function applyPairDataToWidget( baseParams[key] = pairData[key] ?? undefined } for (const key of reviewKeys) { - baseParams[key] = pairData.reviewTarget?.[key] ?? undefined + baseParams[key] = undefined } const nextParams = Object.keys(baseParams).length > 0 ? baseParams : null @@ -1201,7 +1200,6 @@ function applyPairDataToWidget( } function hydratePairStoreFromColorPairs(colorPairs: PersistedColorPairsState) { - const now = Date.now() const currentContexts = usePairColorStore.getState().contexts const nextContexts: Record = { ...currentContexts } @@ -1216,13 +1214,11 @@ function hydratePairStoreFromColorPairs(colorPairs: PersistedColorPairsState) { ...normalizePairColorContext({ workflowId: pair.workflowId ?? undefined, listing: pair.listing ?? null, - reviewTarget: pair.reviewTarget, indicatorId: pair.indicatorId ?? null, mcpServerId: pair.mcpServerId ?? null, customToolId: pair.customToolId ?? null, skillId: pair.skillId ?? null, }), - updatedAt: now, } } @@ -1248,12 +1244,6 @@ function buildPersistedColorPairs(layout: LayoutNode): PersistedColorPairsState color, workflowId, listing, - reviewTarget: { - reviewSessionId: normalizeOptionalString(context?.reviewTarget?.reviewSessionId), - reviewEntityKind: normalizeOptionalString(context?.reviewTarget?.reviewEntityKind), - reviewEntityId: normalizeOptionalString(context?.reviewTarget?.reviewEntityId), - reviewDraftSessionId: normalizeOptionalString(context?.reviewTarget?.reviewDraftSessionId), - }, indicatorId, mcpServerId, customToolId, @@ -1266,17 +1256,13 @@ function buildPersistedColorPairs(layout: LayoutNode): PersistedColorPairsState function hasLinkedColorPairs(colorPairs?: PersistedColorPairsState): boolean { if (!colorPairs || !Array.isArray(colorPairs.pairs)) return false - return colorPairs.pairs.some( - (pair) => - pair?.color && - (pair.workflowId || - pair.reviewTarget?.reviewSessionId || - Boolean(getListingIdentity(pair.listing)) || - pair.indicatorId || - pair.mcpServerId || - pair.customToolId || - pair.skillId) - ) + return colorPairs.pairs.some((pair) => { + if (!pair?.color) { + return false + } + + return Object.keys(normalizePairColorContext(pair)).length > 0 + }) } function getListingIdentity(listing?: ListingInputValue | null): ListingIdentity | null { @@ -1306,41 +1292,68 @@ function cleanupUnusedPairContexts(layout: LayoutNode) { if (color === 'gray') return if (colorsInUse.has(color)) return const context = contexts[color] - if (hasContextData(context)) { + if (Object.keys(normalizePairColorContext(context)).length > 0) { resetContext(color) } }) } -function clonePairContextIfEmpty(previousColor: PairColor | undefined, nextColor: PairColor) { - if (!previousColor || previousColor === 'gray') return - if (nextColor === 'gray' || nextColor === previousColor) return +function seedPairContextForColorSwitch( + previousColor: PairColor | undefined, + nextColor: PairColor, + currentWidget: WidgetInstance +) { + if (nextColor === 'gray' || nextColor === previousColor) { + return + } const { contexts, setContext } = usePairColorStore.getState() - const source = contexts[previousColor] - const target = contexts[nextColor] + const target = normalizePairColorContext(contexts[nextColor]) + const source = + previousColor && previousColor !== 'gray' + ? normalizePairColorContext(contexts[previousColor]) + : normalizePairColorContext(currentWidget?.params ?? null) + const nextContext: PairColorContext = {} + + if (source.workflowId && !target.workflowId) { + nextContext.workflowId = source.workflowId + } + if (source.listing && !target.listing) { + nextContext.listing = source.listing + } + if (source.indicatorId && !target.indicatorId) { + nextContext.indicatorId = source.indicatorId + } + if (source.mcpServerId && !target.mcpServerId) { + nextContext.mcpServerId = source.mcpServerId + } + if (source.customToolId && !target.customToolId) { + nextContext.customToolId = source.customToolId + } + if (source.skillId && !target.skillId) { + nextContext.skillId = source.skillId + } - if (!hasContextData(source) || hasContextData(target)) { + if (Object.keys(nextContext).length === 0) { return } - setContext(nextColor, { ...source }) + setContext(nextColor, nextContext) } -function findPanelPairColor(node: LayoutNode, panelId: string): PairColor | undefined { +function findPanelWidget(node: LayoutNode, panelId: string): WidgetInstance { if (node.type === 'panel') { - if (node.id === panelId) { - return isPairColor(node.widget?.pairColor) ? node.widget?.pairColor : undefined - } - return undefined + return node.id === panelId ? node.widget : null } for (const child of node.children) { - const color = findPanelPairColor(child, panelId) - if (color) return color + const widget = findPanelWidget(child, panelId) + if (widget) { + return widget + } } - return undefined + return null } function findParentGroupId( @@ -1362,11 +1375,6 @@ function findParentGroupId( return null } -function hasContextData(context?: PairColorContext): boolean { - if (!context) return false - return Object.keys(context).length > 0 -} - function splitPanelIntoVerticalGroup(node: LayoutNode, panelId: string): LayoutNode { return splitPanelIntoGroup(node, panelId, 'vertical') } diff --git a/apps/tradinggoose/blocks/blocks/trading_action.ts b/apps/tradinggoose/blocks/blocks/trading_action.ts index f6b85aceb..2241c7e73 100644 --- a/apps/tradinggoose/blocks/blocks/trading_action.ts +++ b/apps/tradinggoose/blocks/blocks/trading_action.ts @@ -1,12 +1,12 @@ import { DollarIcon } from '@/components/icons/icons' +import type { ListingInputValue } from '@/lib/listing/identity' import type { BlockConfig, SubBlockCondition, SubBlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { buildInputsFromToolParams } from '@/blocks/utils' -import type { ListingInputValue } from '@/lib/listing/identity' import { + getTradingProviderIdsForParam, getTradingProviderParamCatalog, getTradingProviderParamDefinitions, - getTradingProviderIdsForParam, getTradingProviders, } from '@/providers/trading' import { getTradingOrderTypeOptions } from '@/providers/trading/order-types' @@ -185,8 +185,7 @@ const providerParamBlocks = (): SubBlockConfig[] => const inputType = resolveParamInputType(definition) const numericInputType = - (inputType === 'short-input' || inputType === 'long-input') && - definition.type === 'number' + (inputType === 'short-input' || inputType === 'long-input') && definition.type === 'number' ? 'number' : undefined const providerCondition = entry.providers.length @@ -225,6 +224,9 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { .filter((provider) => provider.authType === 'oauth' && provider.oauth) .map((provider) => { const oauth = provider.oauth! + const serviceIds = oauth.credentialServices?.length + ? oauth.credentialServices.map((service) => service.serviceId) + : [oauth.serviceId || oauth.provider] return { id: `${provider.id}Credential`, title: oauth.credentialTitle || `${provider.name} Account`, @@ -232,7 +234,7 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { layout: 'full', required: true, provider: oauth.provider, - serviceId: oauth.serviceId || oauth.provider, + ...(serviceIds.length === 1 ? { serviceId: serviceIds[0] } : { serviceIds }), requiredScopes: oauth.scopes || [], placeholder: oauth.credentialPlaceholder || `Select or connect ${provider.name} account`, condition: { field: 'provider', value: provider.id }, @@ -279,7 +281,7 @@ export const TradingActionBlock: BlockConfig = { }, ...providerCredentialBlocks(), - + { id: 'side', title: 'Action', diff --git a/apps/tradinggoose/blocks/blocks/trading_holdings.ts b/apps/tradinggoose/blocks/blocks/trading_holdings.ts index d146c2555..b89a5a5be 100644 --- a/apps/tradinggoose/blocks/blocks/trading_holdings.ts +++ b/apps/tradinggoose/blocks/blocks/trading_holdings.ts @@ -43,6 +43,9 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { .filter((provider) => provider.authType === 'oauth' && provider.oauth) .map((provider) => { const oauth = provider.oauth! + const serviceIds = oauth.credentialServices?.length + ? oauth.credentialServices.map((service) => service.serviceId) + : [oauth.serviceId || oauth.provider] return { id: `${provider.id}Credential`, title: oauth.credentialTitle || `${provider.name} Account`, @@ -50,7 +53,7 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { layout: 'full', required: true, provider: oauth.provider, - serviceId: oauth.serviceId || oauth.provider, + ...(serviceIds.length === 1 ? { serviceId: serviceIds[0] } : { serviceIds }), requiredScopes: oauth.scopes || [], placeholder: oauth.credentialPlaceholder || `Select or connect ${provider.name} account`, condition: { field: 'provider', value: provider.id }, @@ -64,8 +67,7 @@ export const TradingHoldingsBlock: BlockConfig = { name: 'Trading Holdings', description: 'Fetch a unified account snapshot from supported brokers.', authMode: AuthMode.OAuth, - longDescription: - 'Unified holdings block that returns an account snapshot for Alpaca or Tradier.', + longDescription: 'Unified holdings block that returns an account snapshot for Alpaca or Tradier.', category: 'tools', bgColor: '#115e59', icon: DollarIcon, @@ -114,13 +116,16 @@ export const TradingHoldingsBlock: BlockConfig = { .find((value) => value !== undefined) } const credential = resolveCredential() - const extraFields = getProviderFields(provider, 'holdings').reduce((acc, field) => { - const key = `${provider}_${field.id}` - if (params[key] !== undefined) { - acc[field.id] = params[key] - } - return acc - }, {} as Record) + const extraFields = getProviderFields(provider, 'holdings').reduce( + (acc, field) => { + const key = `${provider}_${field.id}` + if (params[key] !== undefined) { + acc[field.id] = params[key] + } + return acc + }, + {} as Record + ) return { provider, diff --git a/apps/tradinggoose/blocks/blocks/trading_order_detail.ts b/apps/tradinggoose/blocks/blocks/trading_order_detail.ts index 480d2552e..876c3f937 100644 --- a/apps/tradinggoose/blocks/blocks/trading_order_detail.ts +++ b/apps/tradinggoose/blocks/blocks/trading_order_detail.ts @@ -19,6 +19,9 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { .filter((provider) => provider.authType === 'oauth' && provider.oauth) .map((provider) => { const oauth = provider.oauth! + const serviceIds = oauth.credentialServices?.length + ? oauth.credentialServices.map((service) => service.serviceId) + : [oauth.serviceId || oauth.provider] return { id: `${provider.id}Credential`, title: oauth.credentialTitle || `${provider.name} Account`, @@ -26,7 +29,7 @@ const providerCredentialBlocks = (): SubBlockConfig[] => { layout: 'full', required: true, provider: oauth.provider, - serviceId: oauth.serviceId || oauth.provider, + ...(serviceIds.length === 1 ? { serviceId: serviceIds[0] } : { serviceIds }), requiredScopes: oauth.scopes || [], placeholder: oauth.credentialPlaceholder || `Select or connect ${provider.name} account`, condition: { field: 'provider', value: provider.id }, diff --git a/apps/tradinggoose/blocks/types.ts b/apps/tradinggoose/blocks/types.ts index b759b0f32..619941537 100644 --- a/apps/tradinggoose/blocks/types.ts +++ b/apps/tradinggoose/blocks/types.ts @@ -232,6 +232,7 @@ export interface SubBlockConfig { // OAuth specific properties provider?: string serviceId?: string + serviceIds?: string[] requiredScopes?: string[] supportsCredentialSets?: boolean // File selector specific properties diff --git a/apps/tradinggoose/components/icons/provider-icons.tsx b/apps/tradinggoose/components/icons/provider-icons.tsx index ad1388537..961a22e39 100644 --- a/apps/tradinggoose/components/icons/provider-icons.tsx +++ b/apps/tradinggoose/components/icons/provider-icons.tsx @@ -314,11 +314,13 @@ export function YahooIcon(props: SVGProps) { export function AlphaVantageIcon(props: SVGProps) { return ( - - + + + + + + + ) } diff --git a/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts b/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts index d407c242a..a6d507eee 100644 --- a/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts +++ b/apps/tradinggoose/components/listing-selector/selector/resolve-request.ts @@ -1,305 +1,11 @@ -import { - type ListingIdentity, - type ListingResolved, -} from '@/lib/listing/identity' -import { MARKET_API_VERSION } from '@/lib/market/client/constants' +import type { ListingIdentity, ListingResolved } from '@/lib/listing/identity' +import { resolveListingIdentity, type ResolvedListingDetails } from '@/lib/listing/resolve' -export type ResolvedListingDetails = { - base?: string - quote?: string | null - name?: string | null - iconUrl?: string | null - assetClass?: string | null - base_asset_class?: string | null - quote_asset_class?: string | null - marketCode?: string | null - countryCode?: string | null - cityName?: string | null - timeZoneName?: string | null -} - -type MarketSearchResponse = { - data?: T - error?: string -} - -type CodeRow = { code?: string; name?: string | null; iconUrl?: string | null } - -const uniqueNonEmpty = (values: string[]) => { - const seen = new Set() - const result: string[] = [] - for (const value of values) { - const trimmed = value.trim() - if (!trimmed || seen.has(trimmed)) continue - seen.add(trimmed) - result.push(trimmed) - } - return result -} - -const fetchMarketSearch = async ( - path: string, - params: URLSearchParams, - signal?: AbortSignal -): Promise => { - if (!params.get('version')) { - params.set('version', MARKET_API_VERSION) - } - - const response = await fetch(`/api/market/get/${path}?${params.toString()}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - signal, - }) - - let payload: MarketSearchResponse | null = null - try { - payload = (await response.json()) as MarketSearchResponse - } catch { - payload = null - } - - if (!response.ok) { - throw new Error(payload?.error || `Market search failed: ${path}`) - } - if (!payload) return null - if (payload.error) { - throw new Error(payload.error) - } - return payload.data ?? null -} - -const fetchMarketBatch = async ( - path: string, - paramName: string, - ids: string[], - signal?: AbortSignal -): Promise> => { - const uniqueIds = uniqueNonEmpty(ids) - const result: Record = {} - if (!uniqueIds.length) return result - - const params = new URLSearchParams() - uniqueIds.forEach((id) => params.append(paramName, id)) - const data = await fetchMarketSearch(path, params, signal) - - if (!data) { - uniqueIds.forEach((id) => { - result[id] = null - }) - return result - } - - if (uniqueIds.length === 1) { - const single = data && typeof data === 'object' ? (data as T) : null - result[uniqueIds[0]] = single - return result - } - - if (typeof data !== 'object' || Array.isArray(data)) { - uniqueIds.forEach((id) => { - result[id] = null - }) - return result - } - - const record = data as Record - uniqueIds.forEach((id) => { - const value = record[id] - result[id] = value && typeof value === 'object' ? (value as T) : null - }) - return result -} - -const toCodeRow = (row: unknown): CodeRow | null => { - if (!row || typeof row !== 'object') return null - const record = row as CodeRow - return { code: record.code, name: record.name ?? null, iconUrl: record.iconUrl ?? null } -} - -const getBatchRow = async ( - path: string, - paramName: string, - id: string, - signal?: AbortSignal -): Promise => { - const records = await fetchMarketBatch(path, paramName, [id], signal) - return records[id] ?? null -} - -const resolveListingById = async ( - listingId: string, - signal?: AbortSignal -): Promise => { - const listing = await getBatchRow('listing', 'listing_id', listingId, signal) - if (!listing || typeof listing !== 'object') return null - return { - base: listing.base, - quote: listing.quote ?? null, - name: listing.name ?? null, - iconUrl: listing.iconUrl ?? null, - assetClass: listing.assetClass ?? null, - marketCode: listing.marketCode ?? null, - countryCode: listing.countryCode ?? null, - cityName: listing.cityName ?? null, - timeZoneName: listing.timeZoneName ?? null, - } -} - -const resolveCurrencyById = async ( - currencyId: string, - signal?: AbortSignal -): Promise<{ code?: string; name?: string | null; iconUrl?: string | null } | null> => { - return toCodeRow(await getBatchRow('currency', 'currency_id', currencyId, signal)) -} - -const resolveCryptoById = async ( - cryptoId: string, - signal?: AbortSignal -): Promise<{ code?: string; name?: string | null; iconUrl?: string | null } | null> => { - return toCodeRow(await getBatchRow('crypto', 'crypto_id', cryptoId, signal)) -} - -const resolveCurrencyPair = async ( - baseId: string, - quoteId: string, - signal?: AbortSignal -): Promise => { - const records = await fetchMarketBatch('currency', 'currency_id', [baseId, quoteId], signal) - const baseRow = toCodeRow(records[baseId]) - const quoteRow = toCodeRow(records[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: `${baseName} to ${quoteName} pair`, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'currency', - base_asset_class: 'currency', - quote_asset_class: 'currency', - } -} - -const resolveCryptoPair = async ( - baseId: string, - quoteId: string, - signal?: AbortSignal -): Promise => { - const records = await fetchMarketBatch('crypto', 'crypto_id', [baseId, quoteId], signal) - const baseRow = toCodeRow(records[baseId]) - const quoteRow = toCodeRow(records[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: baseName && quoteName ? `${baseName} to ${quoteName} pair` : null, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'crypto', - base_asset_class: 'crypto', - quote_asset_class: 'crypto', - } -} - -const resolveCryptoWithCurrencyQuote = async ( - baseId: string, - quoteId: string, - signal?: AbortSignal -): Promise => { - const [cryptoRecords, currencyRecords] = await Promise.all([ - fetchMarketBatch('crypto', 'crypto_id', [baseId], signal), - fetchMarketBatch('currency', 'currency_id', [quoteId], signal), - ]) - const baseRow = toCodeRow(cryptoRecords[baseId]) - const quoteRow = toCodeRow(currencyRecords[quoteId]) - if (!baseRow?.code || !quoteRow?.code) return null - const baseName = baseRow.name?.trim() || baseRow.code - const quoteName = quoteRow.name?.trim() || quoteRow.code - return { - base: baseRow.code, - quote: quoteRow.code, - name: `${baseName} to ${quoteName} pair`, - iconUrl: baseRow.iconUrl ?? null, - assetClass: 'crypto', - base_asset_class: 'crypto', - quote_asset_class: 'currency', - } -} +export type { ResolvedListingDetails } export async function requestListingResolution( listing: ListingIdentity, signal?: AbortSignal ): Promise { - if (!listing) return null - - const listingType = listing.listing_type - const listingId = listing.listing_id?.trim() - const baseId = listing.base_id?.trim() - const quoteId = listing.quote_id?.trim() - - if (listingType === 'default') { - if (!listingId) return null - const details = await resolveListingById(listingId, signal) - return details ? buildResolvedListing(listing, details) : null - } - - if (!baseId || !quoteId) return null - - if (listingType === 'currency') { - const details = await resolveCurrencyPair(baseId, quoteId, signal) - return details ? buildResolvedListing(listing, details) : null - } - - if (listingType === 'crypto') { - const isCryptoQuote = quoteId.toUpperCase().includes('CRYP') - const details = isCryptoQuote - ? await resolveCryptoPair(baseId, quoteId, signal) - : await resolveCryptoWithCurrencyQuote(baseId, quoteId, signal) - return details ? buildResolvedListing(listing, details) : null - } - - return null -} - -function buildResolvedListing( - listing: ListingIdentity, - details: ResolvedListingDetails -): ListingResolved | null { - const base = details.base?.trim() - if (!base) return null - - const normalizedIdentity: ListingIdentity = - listing.listing_type === 'default' - ? { - listing_id: listing.listing_id?.trim() ?? '', - base_id: '', - quote_id: '', - listing_type: listing.listing_type, - } - : { - listing_id: '', - base_id: listing.base_id?.trim() ?? '', - quote_id: listing.quote_id?.trim() ?? '', - listing_type: listing.listing_type, - } - - return { - ...normalizedIdentity, - base, - quote: details.quote ?? null, - name: details.name ?? null, - iconUrl: details.iconUrl ?? null, - assetClass: details.assetClass ?? null, - base_asset_class: details.base_asset_class ?? null, - quote_asset_class: details.quote_asset_class ?? null, - marketCode: details.marketCode ?? null, - countryCode: details.countryCode ?? null, - cityName: details.cityName ?? null, - timeZoneName: details.timeZoneName ?? null, - } + return resolveListingIdentity(listing, signal) } diff --git a/apps/tradinggoose/components/ui/chart.tsx b/apps/tradinggoose/components/ui/chart.tsx new file mode 100644 index 000000000..0dc87da7f --- /dev/null +++ b/apps/tradinggoose/components/ui/chart.tsx @@ -0,0 +1,331 @@ +'use client' + +import * as React from 'react' +import type { LegendPayload, TooltipContentProps, TooltipValueType } from 'recharts' +import * as RechartsPrimitive from 'recharts' +import { cn } from '@/lib/utils' + +const THEMES = { light: '', dark: '.dark' } as const + +export type ChartConfig = { + [key: string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { + color?: string + theme?: never + } + | { + color?: never + theme: Record + } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error('useChart must be used within a ') + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig + children: React.ComponentProps['children'] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` + + return ( + +
+ + {children} +
+ + ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, itemConfig]) => itemConfig.theme || itemConfig.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +