diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b871287d42e..e21a8732011 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -15,7 +15,6 @@ RUN apt-get update -qq \ libyaml-dev \ libyaml-0-2 \ openssh-client \ - postgresql-client \ vim \ procps \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index d1825c041aa..136407af806 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,18 +1,6 @@ -x-db-env: &db_env - POSTGRES_USER: postgres - POSTGRES_DB: postgres - POSTGRES_PASSWORD: postgres - x-rails-env: &rails_env - DB_HOST: db - # Bind the dev server to all interfaces inside the container so Docker's - # published port reaches it from the host. Rails reads BINDING natively. - BINDING: "0.0.0.0" - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + HOST: "0.0.0.0" BUNDLE_PATH: /bundle - REDIS_URL: redis://redis:6379/1 - SELENIUM_REMOTE_URL: http://selenium:4444 services: app: @@ -28,40 +16,6 @@ services: command: sleep infinity environment: <<: *rails_env - depends_on: - - db - - redis - - selenium - - worker: - build: - context: .. - dockerfile: .devcontainer/Dockerfile - volumes: - - ..:/workspace:cached - - bundle_cache:/bundle - command: bundle exec sidekiq - restart: unless-stopped - environment: - <<: *rails_env - depends_on: - - redis - - redis: - image: redis:latest - volumes: - - redis-data:/data - restart: unless-stopped - - db: - image: postgres:16 - volumes: - - postgres-data:/var/lib/postgresql/data - ports: - - "5432:5432" - restart: unless-stopped - environment: - <<: *db_env selenium: image: selenium/standalone-chromium:latest @@ -72,6 +26,4 @@ services: restart: unless-stopped volumes: - postgres-data: - redis-data: bundle_cache: diff --git a/.env.example b/.env.example index e0bc454b1db..cc0df22880f 100644 --- a/.env.example +++ b/.env.example @@ -95,22 +95,6 @@ SMTP_TLS_SKIP_VERIFY=false EMAIL_SENDER= # Database Configuration -DB_HOST=localhost # May need to be changed to `DB_HOST=db` if using devcontainer -DB_PORT=5432 -POSTGRES_PASSWORD=postgres # pipelock:ignore -POSTGRES_USER=postgres - -# Redis configuration -# Standard Redis URL (for direct connection) -REDIS_URL=redis://localhost:6379/1 - -# Redis Sentinel configuration (for high availability) -# When REDIS_SENTINEL_HOSTS is set, it takes precedence over REDIS_URL -# REDIS_SENTINEL_HOSTS=sentinel1:26379,sentinel2:26379,sentinel3:26379 -# REDIS_SENTINEL_MASTER=mymaster -# REDIS_SENTINEL_USERNAME=default -# REDIS_PASSWORD=your-redis-password # pipelock:ignore - # App Domain # This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae6bb5e342..4484f0357a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: workflow_call: + workflow_dispatch: jobs: scan_ruby: @@ -9,7 +10,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -25,7 +26,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -41,7 +42,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -57,10 +58,10 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js environment - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" @@ -79,32 +80,14 @@ jobs: env: PLAID_CLIENT_ID: foo PLAID_SECRET: bar - DATABASE_URL: postgres://postgres:postgres@localhost:5432 - REDIS_URL: redis://localhost:6379 RAILS_ENV: test - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 - - redis: - image: redis - ports: - - 6379:6379 - options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 - steps: - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y curl libvips - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -121,8 +104,28 @@ jobs: - name: Unit and integration tests run: bin/rails test + system-test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + RAILS_ENV: test + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + - name: System tests - run: DISABLE_PARALLELIZATION=true bin/rails test:system + run: bin/rails test:system - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afdfd35a76a..4972131ddb5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -73,7 +73,7 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v4.2.0 + uses: actions/checkout@v6 with: ref: ${{ github.event.inputs.ref || github.ref }} diff --git a/.ruby-version b/.ruby-version index 2aa51319921..4b4ea1b6883 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +3.4.6 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2cb2c83e796..a58ed16754b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,30 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.7 +ARG RUBY_VERSION=3.4.6 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails # Install base packages +# procps package is needed for litestream because it uses the ps command internally RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 procps \ + && apt-get install --no-install-recommends -y curl libvips libyaml-0-2 procps \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment -ARG BUILD_COMMIT_SHA ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" \ - BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} - + BUNDLE_WITHOUT="development" + # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems RUN apt-get update -qq \ - && apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev \ + && apt-get install --no-install-recommends -y build-essential git pkg-config libyaml-dev \ && rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems @@ -55,6 +54,9 @@ USER 1000:1000 COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" COPY --chown=rails:rails --from=build /rails /rails +ARG BUILD_COMMIT_SHA +ENV BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA} + # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] diff --git a/Gemfile b/Gemfile index 19fdf8a1770..59f2b0ca176 100644 --- a/Gemfile +++ b/Gemfile @@ -3,11 +3,7 @@ source "https://rubygems.org" ruby file: ".ruby-version" # Rails -gem "rails", "~> 7.2.2" - -# Drivers -gem "pg", "~> 1.5" -gem "redis", "~> 5.4" +gem "rails", "~> 8.1.1" # Deployment gem "puma", ">= 5.0" @@ -24,25 +20,15 @@ gem "stimulus-rails" gem "turbo-rails" gem "view_component" -# https://github.com/lookbook-hq/lookbook/issues/712 -# TODO: Remove max version constraint when fixed -gem "lookbook", "2.3.11" - gem "hotwire_combobox" # Background Jobs -gem "connection_pool", "~> 2.5" # pin to 2.x; 3.0 breaks sidekiq 8.x -gem "sidekiq" -gem "sidekiq-cron" -gem "sidekiq-unique-jobs" # Monitoring gem "vernier" gem "rack-mini-profiler" gem "sentry-ruby" gem "sentry-rails" -gem "sentry-sidekiq" -gem "posthog-ruby" gem "logtail-rails" gem "skylight", groups: [ :production ] @@ -61,6 +47,7 @@ gem "countries" # OAuth & API Security gem "doorkeeper" +gem "rswag-ui" gem "rack-attack", "~> 6.6" gem "rack-cors" gem "pundit" @@ -69,10 +56,9 @@ gem "faraday-retry" gem "faraday-multipart" gem "inline_svg" gem "octokit" -gem "pagy" -gem "rails-i18n" +gem "pagy", "~> 9.0" gem "rails-settings-cached" -gem "tzinfo-data", platforms: %i[windows jruby] +gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] gem "csv" gem "rchardet" # Character encoding detection gem "redcarpet" @@ -104,12 +90,13 @@ gem "ruby-openai" gem "langfuse-ruby", "~> 0.1.4", require: "langfuse" group :development, :test do - gem "debug", platforms: %i[mri windows] + gem "debug", platforms: %i[mri mingw mswin x64_mingw] gem "brakeman", require: false gem "rubocop-rails-omakase", require: false gem "i18n-tasks" gem "erb_lint" gem "dotenv-rails" + gem "rswag-specs" end if ENV["BENCHMARKING_ENABLED"] @@ -121,6 +108,9 @@ group :development do gem "letter_opener" gem "ruby-lsp-rails" gem "web-console" + # https://github.com/lookbook-hq/lookbook/issues/712 + # TODO: Remove max version constraint when fixed + gem "lookbook", "2.3.11" gem "faker" gem "benchmark-ips" gem "stackprof" @@ -144,3 +134,23 @@ group :test do gem "climate_control" gem "simplecov", require: false end + +gem "rails_db", "~> 2.5" + +gem "seed_dump", github: "rroblak/seed_dump" + +gem "solid_cache", "~> 1.0" + +gem "sqlite3", "~> 2.7" + +gem "solid_queue", "~> 1.2" + +gem "solid_cable", "~> 3.0" + +gem "mission_control-jobs", "~> 1.1" + +gem "sqlean", "~> 0.3.0" + +gem "litestream", "~> 0.14.0" + +gem "honeybadger", "~> 6.1" diff --git a/Gemfile.lock b/Gemfile.lock index 2b3ab3c5f3d..c24bfe9aab7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,84 +1,100 @@ +GIT + remote: https://github.com/maybe-finance/lucide-rails.git + revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0 + specs: + lucide-rails (0.2.0) + railties (>= 4.1.0) + +GIT + remote: https://github.com/rroblak/seed_dump.git + revision: c2fa31924c1f725450619b30a40e94f6b2d632b4 + specs: + seed_dump (3.3.1) + activerecord (>= 4) + activesupport (>= 4) + GEM remote: https://rubygems.org/ specs: - Ascii85 (2.0.1) - aasm (5.5.1) + aasm (5.5.2) concurrent-ruby (~> 1.0) - actioncable (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) - actionmailer (7.2.3.1) - actionpack (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.3.1) - actionview (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.3) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.3.1) - actionpack (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actiontext (8.1.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.3.1) - activesupport (= 7.2.3.1) + actionview (8.1.1) + activesupport (= 8.1.1) builder (~> 3.1) cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.3.1) - activesupport (= 7.2.3.1) + activejob (8.1.1) + activesupport (= 8.1.1) globalid (>= 0.3.6) - activemodel (7.2.3.1) - activesupport (= 7.2.3.1) - activerecord (7.2.3.1) - activemodel (= 7.2.3.1) - activesupport (= 7.2.3.1) + activemodel (8.1.1) + activesupport (= 8.1.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) timeout (>= 0.4.0) activerecord-import (2.2.0) activerecord (>= 4.2) - activestorage (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activesupport (= 7.2.3.1) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) marcel (~> 1.0) - activesupport (7.2.3.1) + activesupport (8.1.1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) @@ -90,8 +106,8 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1196.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1180.0) + aws-sdk-core (3.236.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -99,8 +115,8 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.116.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.208.0) aws-sdk-core (~> 3, >= 3.234.0) @@ -109,22 +125,20 @@ GEM aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.22) - benchmark (0.5.0) + bcrypt (3.1.20) benchmark-ips (2.14.0) - better_html (2.1.1) - actionview (>= 6.0) - activesupport (>= 6.0) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties bigdecimal (3.3.1) - bindata (2.5.1) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.4) racc builder (3.3.0) capybara (3.40.0) @@ -142,24 +156,18 @@ GEM logger (~> 1.5) chunky_png (1.4.0) climate_control (1.2.0) - concurrent-ruby (1.3.6) - connection_pool (2.5.5) - cose (1.3.1) - cbor (~> 0.5.9) - openssl-signature_algorithm (~> 1.0) - countries (8.0.3) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + countries (8.0.4) unaccent (~> 0.3) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml crass (1.0.6) - cronex (0.15.0) - tzinfo - unicode (>= 0.4.4.5) css_parser (1.21.1) addressable csv (3.3.5) - date (3.4.1) + date (3.5.0) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -193,10 +201,7 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) - ed25519 (1.4.0) - email_validator (2.2.4) - activemodel - erb (5.0.1) + erb (6.0.0) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -205,12 +210,12 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo event_stream_parser (1.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -223,67 +228,28 @@ GEM faraday-retry (2.3.2) faraday (~> 2.0) ffi (1.17.2-aarch64-linux-gnu) - ffi (1.17.2-aarch64-linux-musl) - ffi (1.17.2-arm-linux-gnu) - ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) - ffi (1.17.2-x86_64-linux-musl) - foreman (0.88.1) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + foreman (0.90.0) + thor (~> 1.4) + fugit (1.12.1) + et-orbi (~> 1.4) raabro (~> 1.4) get_process_mem (1.0.0) bigdecimal (>= 2.0) ffi (~> 1.0) globalid (1.3.0) activesupport (>= 6.1) - google-apis-core (1.0.2) - addressable (~> 2.8, >= 2.8.7) - faraday (~> 2.13) - faraday-follow_redirects (~> 0.3) - googleauth (~> 1.14) - mini_mime (~> 1.1) - representable (~> 3.0) - retriable (~> 3.1) - google-apis-iamcredentials_v1 (0.26.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.61.0) - google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.8.0) - google-cloud-env (>= 1.0, < 3.a) - google-cloud-errors (~> 1.0) - google-cloud-env (2.3.1) - base64 (~> 0.2) - faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.59.0) - addressable (~> 2.8) - digest-crc (~> 0.4) - google-apis-core (>= 0.18, < 2) - google-apis-iamcredentials_v1 (~> 0.18) - google-apis-storage_v1 (>= 0.42) - google-cloud-core (~> 1.6) - googleauth (~> 1.9) - mini_mime (~> 1.0) - google-logging-utils (0.2.0) - googleauth (1.16.2) - faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.2) - google-logging-utils (~> 0.1) - jwt (>= 1.4, < 4.0) - multi_json (~> 1.11) - os (>= 0.9, < 2.0) - signet (>= 0.16, < 2.a) - hashdiff (1.2.0) - hashery (2.1.2) - hashie (5.0.0) + hashdiff (1.2.1) heapy (0.2.0) thor highline (3.1.2) reline - hotwire-livereload (2.0.0) + honeybadger (6.1.3) + logger + ostruct + hotwire-livereload (2.1.1) actioncable (>= 7.0.0) listen (>= 3.0.0) railties (>= 7.0.0) @@ -294,7 +260,7 @@ GEM turbo-rails (>= 1.2) htmlbeautifier (1.4.3) htmlentities (4.3.4) - httparty (0.24.0) + httparty (0.23.2) csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) @@ -314,35 +280,40 @@ GEM image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) - importmap-rails (2.1.0) + importmap-rails (2.2.2) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.0) - irb (1.15.2) + io-console (0.8.1) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.13.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.19.2) - json-jwt (1.16.7) - activesupport (>= 4.2) - aes_key_wrap - base64 - bindata - faraday (~> 2.0) - faraday-follow_redirects - json-schema (5.2.2) + json (2.15.2) + json-schema (6.2.0) addressable (~> 2.8) - bigdecimal (~> 3.1) - jwt (2.10.2) + bigdecimal (>= 3.1, < 5) + jwt (3.1.2) base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) langfuse-ruby (0.1.4) concurrent-ruby (~> 1.0) faraday (>= 1.8, < 3.0) @@ -359,13 +330,41 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + litestream (0.14.0-aarch64-linux) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 + litestream (0.14.0-arm64-darwin) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 + litestream (0.14.0-x86_64-darwin) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 + litestream (0.14.0-x86_64-linux) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 logger (1.7.0) logtail (0.1.17) msgpack (~> 1.0) logtail-rack (0.2.6) logtail (~> 0.1) rack (>= 1.2, < 4.0) - logtail-rails (0.2.10) + logtail-rails (0.2.11) actionpack (>= 5.0.0) activerecord (>= 5.0.0) logtail (~> 0.1, >= 0.1.14) @@ -386,23 +385,30 @@ GEM view_component (>= 2.0) yard (~> 0.9) zeitwerk (~> 2.5) - lucide-rails (0.7.3) - railties (>= 4.1.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp marcel (1.1.0) - matrix (0.4.2) + matrix (0.4.3) memory_profiler (1.1.0) - method_source (1.1.0) mini_histogram (0.3.1) - mini_magick (5.2.0) - benchmark + mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.27.0) + minitest (5.26.1) + mission_control-jobs (1.1.0) + actioncable (>= 7.1) + actionpack (>= 7.1) + activejob (>= 7.1) + activerecord (>= 7.1) + importmap-rails (>= 1.2.1) + irb (~> 1.13) + railties (>= 7.1) + stimulus-rails + turbo-rails mocha (2.7.1) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -411,9 +417,9 @@ GEM bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) mutex_m (0.3.0) - net-http (0.9.1) - uri (>= 0.11.1) - net-imap (0.5.8) + net-http (0.7.0) + uri + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -422,22 +428,14 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.19.2-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.2-aarch64-linux-musl) - racc (~> 1.4) - nokogiri (1.19.2-arm-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.2-arm-linux-musl) + nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-arm64-darwin) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-darwin) + nokogiri (1.18.10-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-musl) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) @@ -450,94 +448,40 @@ GEM octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) - omniauth (2.1.3) - hashie (>= 3.4.6) - rack (>= 2.2.3) - rack-protection - omniauth-github (2.0.1) - omniauth (~> 2.0) - omniauth-oauth2 (~> 1.8) - omniauth-google-oauth2 (1.2.1) - jwt (>= 2.9.2) - oauth2 (~> 2.0) - omniauth (~> 2.0) - omniauth-oauth2 (~> 1.8) - omniauth-oauth2 (1.8.0) - oauth2 (>= 1.4, < 3) - omniauth (~> 2.0) - omniauth-rails_csrf_protection (1.0.2) - actionpack (>= 4.2) - omniauth (~> 2.0) - omniauth-saml (2.2.4) - omniauth (~> 2.1) - ruby-saml (~> 1.18) - omniauth_openid_connect (0.8.0) - omniauth (>= 1.9, < 3) - openid_connect (~> 2.2) - openid_connect (2.3.1) - activemodel - attr_required (>= 1.0.0) - email_validator - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.16) - mail - rack-oauth2 (~> 2.2) - swd (~> 2.0) - tzinfo - validate_url - webfinger (~> 2.0) - openssl (4.0.1) - openssl-signature_algorithm (1.3.0) - openssl (> 2.0) - os (1.1.4) - ostruct (0.6.2) - pagy (9.3.5) + ostruct (0.6.3) + pagy (9.4.0) parallel (1.27.0) - parser (3.3.10.2) + parser (3.3.10.0) ast (~> 2.4.1) racc - pdf-reader (2.15.1) - Ascii85 (>= 1.0, < 3.0, != 2.0.0) - afm (>= 0.2.1, < 2) - hashery (~> 2.0) - ruby-rc4 - ttfunk - pg (1.5.9) - plaid (41.0.0) + plaid (44.1.0) faraday (>= 1.0.1, < 3.0) faraday-multipart (>= 1.0.1, < 2.0) platform_agent (1.0.1) activesupport (>= 5.2.0) useragent (~> 0.16.3) - posthog-ruby (3.3.3) - concurrent-ruby (~> 1) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.4.0) - propshaft (1.1.0) + prism (1.6.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) psych (5.2.6) date stringio public_suffix (6.0.2) - puma (6.6.0) + puma (7.1.0) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.6) - rack-attack (6.7.0) + rack (3.2.4) + rack-attack (6.8.0) rack (>= 1.0, < 4) - rack-cors (3.0.0) - logger - rack (>= 3.0.14) - rack-mini-profiler (4.0.0) + rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-oauth2 (2.2.1) activesupport @@ -557,20 +501,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (7.2.3.1) - actioncable (= 7.2.3.1) - actionmailbox (= 7.2.3.1) - actionmailer (= 7.2.3.1) - actionpack (= 7.2.3.1) - actiontext (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activemodel (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) bundler (>= 1.15.0) - railties (= 7.2.3.1) + railties (= 8.1.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -578,16 +522,23 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (7.0.10) + rails-i18n (8.0.2) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 8) + railties (>= 8.0.0, < 9) rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + rails_db (2.5.0) + activerecord + csv + kaminari + rails (>= 5.0.0) + ransack (>= 2.3.2) + simple_form (>= 5.0.1) + terminal-table + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -595,65 +546,43 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) + rake (13.3.1) + ransack (4.4.1) + activerecord (>= 7.2) + activesupport (>= 7.2) + i18n rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.9.4) + rbs (3.9.5) logger - rchardet (1.10.0) - rdoc (6.14.2) + rdoc (6.15.1) erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) - redis (5.4.0) - redis-client (>= 0.22.0) - redis-client (0.25.0) - connection_pool - regexp_parser (2.10.0) - reline (0.6.1) + regexp_parser (2.11.3) + reline (0.6.3) io-console (~> 0.5) - representable (3.2.0) - declarative (< 0.1.0) - trailblazer-option (>= 0.1.1, < 0.2.0) - uber (< 0.2.0) - retriable (3.4.1) - rexml (3.4.2) + rexml (3.4.4) rotp (6.3.0) - rouge (4.5.2) + rouge (4.6.1) rqrcode (3.1.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.6) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-rails (8.0.2) - actionpack (>= 7.2) - activesupport (>= 7.2) - railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.6) - rswag-api (2.16.0) - activesupport (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rswag-specs (2.16.0) - activesupport (>= 5.2, < 8.1) - json-schema (>= 2.2, < 6.0) - railties (>= 5.2, < 8.1) + rspec-support (3.13.7) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) rspec-core (>= 2.14) - rswag-ui (2.16.0) - actionpack (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rubocop (1.76.1) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -661,17 +590,17 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.1) + rubocop-ast (1.47.1) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-performance (1.25.0) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.32.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.33.4) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -681,13 +610,13 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.26.9) + ruby-lsp (0.26.2) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) ruby-lsp-rails (0.4.8) ruby-lsp (>= 0.26.0, < 0.27.0) - ruby-openai (8.1.0) + ruby-openai (8.3.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) faraday-multipart (>= 1) @@ -697,110 +626,90 @@ GEM nokogiri (>= 1.13.10) rexml ruby-statistics (4.1.0) - ruby-vips (2.2.4) + ruby-vips (2.2.5) ffi (~> 1.12) logger ruby2_keywords (0.0.5) rubyzip (2.4.1) - safety_net_attestation (0.5.0) - jwt (>= 2.0, < 4.0) - sawyer (0.9.2) + sawyer (0.9.3) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) - selenium-webdriver (4.34.0) + selenium-webdriver (4.38.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (5.26.0) - railties (>= 5.0) - sentry-ruby (~> 5.26.0) - sentry-ruby (5.26.0) + sentry-rails (6.1.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.1.0) + sentry-ruby (6.1.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sentry-sidekiq (5.26.0) - sentry-ruby (~> 5.26.0) - sidekiq (>= 3.0) - sidekiq (8.0.5) - connection_pool (>= 2.5.0) - json (>= 2.9.0) - logger (>= 1.6.2) - rack (>= 3.1.0) - redis-client (>= 0.23.2) - sidekiq-cron (2.3.0) - cronex (>= 0.13.0) - fugit (~> 1.8, >= 1.11.1) - globalid (>= 1.0.1) - sidekiq (>= 6.5.0) - sidekiq-unique-jobs (8.0.11) - concurrent-ruby (~> 1.0, >= 1.0.5) - sidekiq (>= 7.0.0, < 9.0.0) - thor (>= 1.0, < 3.0) - signet (0.21.0) - addressable (~> 2.8) - faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 4.0) - multi_json (~> 1.10) + simple_form (5.4.0) + actionpack (>= 7.0) + activemodel (>= 7.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.1) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - skylight (6.0.4) - activesupport (>= 5.2.0) + skylight (7.0.0) + activesupport (>= 7.1.0) smart_properties (1.17.0) - snaky_hash (2.0.3) - hashie (>= 0.1.0, < 6) - version_gem (>= 1.1.8, < 3) - snaptrade (2.0.156) - faraday (>= 1.0.1, < 3.0) - faraday-multipart (~> 1.0, >= 1.0.4) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.8) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.2.4) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlean (0.3.0-aarch64-linux-gnu) + sqlean (0.3.0-arm64-darwin) + sqlean (0.3.0-x86_64-darwin) + sqlean (0.3.0-x86_64-linux-gnu) + sqlite3 (2.8.0-aarch64-linux-gnu) + sqlite3 (2.8.0-arm64-darwin) + sqlite3 (2.8.0-x86_64-darwin) + sqlite3 (2.8.0-x86_64-linux-gnu) stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) - stripe (15.3.0) - swd (2.0.3) - activesupport (>= 3) - attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects - tailwindcss-rails (4.2.3) + stringio (3.1.8) + stripe (17.2.0) + tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.8) - tailwindcss-ruby (4.1.8-aarch64-linux-gnu) - tailwindcss-ruby (4.1.8-aarch64-linux-musl) - tailwindcss-ruby (4.1.8-arm64-darwin) - tailwindcss-ruby (4.1.8-x86_64-darwin) - tailwindcss-ruby (4.1.8-x86_64-linux-gnu) - tailwindcss-ruby (4.1.8-x86_64-linux-musl) + tailwindcss-ruby (4.1.16-aarch64-linux-gnu) + tailwindcss-ruby (4.1.16-arm64-darwin) + tailwindcss-ruby (4.1.16-x86_64-darwin) + tailwindcss-ruby (4.1.16-x86_64-linux-gnu) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.4.0) - timeout (0.6.1) - tpm-key_attestation (0.14.1) - bindata (~> 2.4) - openssl (> 2.0) - openssl-signature_algorithm (~> 1.0) - trailblazer-option (0.1.2) + timeout (0.4.4) tsort (0.2.0) - ttfunk (1.8.0) - bigdecimal (~> 3.1) - turbo-rails (2.0.16) + turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) unaccent (0.4.0) - unicode (0.4.4.5) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) @@ -808,30 +717,16 @@ GEM public_suffix vcr (6.3.1) base64 - vernier (1.8.0) - version_gem (1.1.9) - view_component (3.23.2) - activesupport (>= 5.2.0, < 8.1) + vernier (1.8.1) + view_component (4.1.0) + activesupport (>= 7.1.0, < 8.2) concurrent-ruby (~> 1) - method_source (~> 1.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webauthn (3.4.3) - android_key_attestation (~> 0.3.0) - bindata (~> 2.4) - cbor (~> 0.5.9) - cose (~> 1.1) - openssl (>= 2.2) - safety_net_attestation (~> 0.5.0) - tpm-key_attestation (~> 0.14.0) - webfinger (2.1.3) - activesupport - faraday (~> 2.0) - faraday-follow_redirects - webmock (3.25.1) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -847,13 +742,9 @@ GEM PLATFORMS aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl arm64-darwin x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl + x86_64-linux DEPENDENCIES aasm @@ -880,7 +771,7 @@ DEPENDENCIES faraday-multipart faraday-retry foreman - google-cloud-storage (~> 1.59) + honeybadger (~> 6.1) hotwire-livereload hotwire_combobox httparty @@ -892,9 +783,11 @@ DEPENDENCIES jwt langfuse-ruby (~> 0.1.4) letter_opener + litestream (~> 0.14.0) logtail-rails lookbook (= 2.3.11) - lucide-rails + lucide-rails! + mission_control-jobs (~> 1.1) mocha octokit omniauth (~> 2.1) @@ -904,9 +797,7 @@ DEPENDENCIES omniauth-saml (~> 2.1) omniauth_openid_connect ostruct - pagy - pdf-reader (~> 2.12) - pg (~> 1.5) + pagy (~> 9.0) plaid posthog-ruby propshaft @@ -915,32 +806,29 @@ DEPENDENCIES rack-attack (~> 6.6) rack-cors rack-mini-profiler - rails (~> 7.2.2) - rails-i18n + rails (~> 8.1.1) rails-settings-cached - rchardet + rails_db (~> 2.5) redcarpet - redis (~> 5.4) rotp (~> 6.3) rqrcode (~> 3.0) - rspec-rails - rswag-api rswag-specs rswag-ui rubocop-rails-omakase ruby-lsp-rails ruby-openai rubyzip (~> 2.3) + seed_dump! selenium-webdriver sentry-rails sentry-ruby - sentry-sidekiq - sidekiq - sidekiq-cron - sidekiq-unique-jobs simplecov skylight - snaptrade (~> 2.0) + solid_cable (~> 3.0) + solid_cache (~> 1.0) + solid_queue (~> 1.2) + sqlean (~> 0.3.0) + sqlite3 (~> 2.7) stackprof stimulus-rails stripe @@ -955,7 +843,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.7p58 + ruby 3.4.6p54 BUNDLED WITH - 2.6.7 + 2.7.2 diff --git a/Procfile.dev b/Procfile.dev index a868e97a4c6..bbfe2ccc8ef 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,2 @@ -web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server +web: OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0 css: bundle exec bin/rails tailwindcss:watch 2>/dev/null -worker: bundle exec sidekiq diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index efcdca7d66b..5999b159dbb 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -41,13 +41,32 @@ <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: comparison_label } %> + <% if secondary_series.present? %> +
+
+ + <%= primary_series_label %> +
+ +
+ + <%= secondary_series_label %> +
+
+ <% end %> +
<% if series.any? %>
+ data-time-series-chart-data-value="<%= series.to_json %>" + <% if secondary_series.present? %> + data-time-series-chart-primary-series-label-value="<%= primary_series_label %>" + data-time-series-chart-secondary-data-value="<%= secondary_series.to_json %>" + data-time-series-chart-secondary-series-label-value="<%= secondary_series_label %>" + <% end %>>
<% else %>

No data available

diff --git a/app/components/UI/account/chart.rb b/app/components/UI/account/chart.rb index ee55e499a8b..24a6c8b7489 100644 --- a/app/components/UI/account/chart.rb +++ b/app/components/UI/account/chart.rb @@ -62,12 +62,34 @@ def converted_balance_money end end + def primary_series + @primary_series ||= account.balance_series(period: period, view: view) + end + + def secondary_series + return nil unless foreign_currency? + + @secondary_series ||= account.balance_series( + period: period, + view: view, + currency: account.family.currency + ) + end + + def primary_series_label + account.currency + end + + def secondary_series_label + "#{account.family.currency} equivalent" + end + def view @view ||= "balance" end def series - account.balance_series(period: period, view: view) + primary_series end def trend diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5e849a2c45f..f19ae4437e5 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -26,54 +26,12 @@ def index }, status: :internal_server_error end - def show - unless valid_uuid?(params[:id]) - render json: { - error: "not_found", - message: "Account not found" - }, status: :not_found - return - end - - @account = accounts_scope.find(params[:id]) - - render :show - rescue ActiveRecord::RecordNotFound - render json: { - error: "not_found", - message: "Account not found" - }, status: :not_found - rescue => e - Rails.logger.error "AccountsController#show error: #{e.message}" - Rails.logger.error e.backtrace.join("\n") - - render json: { - error: "internal_server_error", - message: "An unexpected error occurred" - }, status: :internal_server_error - end - private def ensure_read_scope authorize_scope!(:read) end - def accounts_scope - scope = current_resource_owner.family.accounts - .accessible_by(current_resource_owner) - .includes(:accountable, account_providers: :provider) - include_disabled_accounts? ? scope : scope.visible - end - - def include_disabled_accounts? - ActiveModel::Type::Boolean.new.cast(params[:include_disabled]) - end - - def valid_uuid?(value) - value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i) - end - def safe_page_param page = params[:page].to_i page > 0 ? page : 1 @@ -82,6 +40,7 @@ def safe_page_param def safe_per_page_param per_page = params[:per_page].to_i + # Default to 25, max 100 case per_page when 1..100 per_page diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index c92e80334b3..483ad776026 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -4,7 +4,6 @@ class AuthController < BaseController include Invitable skip_before_action :authenticate_request! - skip_before_action :check_api_key_rate_limit skip_before_action :log_api_access before_action :authenticate_request!, only: :enable_ai before_action :ensure_write_scope, only: :enable_ai diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index dccaae77c06..c708377f08c 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -19,11 +19,8 @@ class Api::V1::BaseController < ApplicationController before_action :force_json_format # Use our custom authentication that supports both OAuth and API keys before_action :authenticate_request! - before_action :check_api_key_rate_limit before_action :log_api_access - - # Override Doorkeeper's default behavior to return JSON instead of redirecting def doorkeeper_unauthorized_render_options(error: nil) { json: { error: "unauthorized", message: "Access token is invalid, expired, or missing" } } @@ -111,55 +108,10 @@ def authenticate_api_key @api_key.update_last_used! @authentication_method = :api_key - @rate_limiter = ApiRateLimiter.limit(@api_key) setup_current_context_for_api true end - # Check rate limits for API key authentication - def check_api_key_rate_limit - return unless @authentication_method == :api_key && @rate_limiter - - if @rate_limiter.rate_limit_exceeded? - usage_info = @rate_limiter.usage_info - render_rate_limit_exceeded(usage_info) - return false - end - - # Increment request count for successful API key requests - @rate_limiter.increment_request_count! - - # Add rate limit headers to response - add_rate_limit_headers(@rate_limiter.usage_info) - end - - # Render rate limit exceeded response - def render_rate_limit_exceeded(usage_info) - response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s - response.headers["X-RateLimit-Remaining"] = "0" - response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s - response.headers["Retry-After"] = usage_info[:reset_time].to_s - - Rails.logger.warn "API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests" - - render_json({ - error: "rate_limit_exceeded", - message: "Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.", - details: { - limit: usage_info[:rate_limit], - current: usage_info[:current_count], - reset_in_seconds: usage_info[:reset_time] - } - }, status: :too_many_requests) - end - - # Add rate limit headers to successful responses - def add_rate_limit_headers(usage_info) - response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s - response.headers["X-RateLimit-Remaining"] = usage_info[:remaining].to_s - response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s - end - # Render unauthorized response def render_unauthorized render_json({ error: "unauthorized", message: "Access token or API key is invalid, expired, or missing" }, status: :unauthorized) diff --git a/app/controllers/api/v1/transactions_controller.rb b/app/controllers/api/v1/transactions_controller.rb index c3538c79ea3..f55e2c3e78f 100644 --- a/app/controllers/api/v1/transactions_controller.rb +++ b/app/controllers/api/v1/transactions_controller.rb @@ -104,7 +104,7 @@ def create error: "internal_server_error", message: "An unexpected error occurred" }, status: :internal_server_error -end + end def update if @entry.split_child? @@ -270,15 +270,15 @@ def apply_filters(query) end def apply_search(query) - search_term = "%#{params[:search]}%" + search_term = "%#{params[:search].downcase}%" query.joins(:entry) .left_joins(:merchant) .where( - "entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?", + "LOWER(entries.name) LIKE ? OR LOWER(entries.notes) LIKE ? OR LOWER(merchants.name) LIKE ?", search_term, search_term, search_term ) -end + end def transaction_params params.require(:transaction).permit( diff --git a/app/controllers/api/v1/usage_controller.rb b/app/controllers/api/v1/usage_controller.rb index b2a57df9e72..9d7bd4ee924 100644 --- a/app/controllers/api/v1/usage_controller.rb +++ b/app/controllers/api/v1/usage_controller.rb @@ -5,21 +5,12 @@ def show case @authentication_method when :api_key - usage_info = @rate_limiter.usage_info render_json({ api_key: { name: @api_key.name, scopes: @api_key.scopes, last_used_at: @api_key.last_used_at, created_at: @api_key.created_at - }, - rate_limit: { - tier: usage_info[:tier], - limit: usage_info[:rate_limit], - current_count: usage_info[:current_count], - remaining: usage_info[:remaining], - reset_in_seconds: usage_info[:reset_time], - reset_at: Time.current + usage_info[:reset_time].seconds } }) when :oauth diff --git a/app/controllers/lookbooks_controller.rb b/app/controllers/lookbooks_controller.rb index 6dc06d7ebf2..0c909a8141f 100644 --- a/app/controllers/lookbooks_controller.rb +++ b/app/controllers/lookbooks_controller.rb @@ -1,3 +1,3 @@ -class LookbooksController < Lookbook::PreviewController +class LookbooksController < (defined?(Lookbook::PreviewController) ? Lookbook::PreviewController : ApplicationController) layout "lookbooks" end diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 98baffc17f7..fb3373b36db 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -6,7 +6,14 @@ const parseLocalDate = d3.timeParse("%Y-%m-%d"); export default class extends Controller { static values = { data: Object, + secondaryData: Object, strokeWidth: { type: Number, default: 2 }, + secondaryStrokeColor: { + type: String, + default: "var(--color-gray-400)", + }, + primarySeriesLabel: String, + secondarySeriesLabel: String, useLabels: { type: Boolean, default: true }, useTooltip: { type: Boolean, default: true }, }; @@ -17,6 +24,7 @@ export default class extends Controller { _d3InitialContainerWidth = 0; _d3InitialContainerHeight = 0; _normalDataPoints = []; + _secondaryDataPoints = []; _resizeObserver = null; connect() { @@ -41,6 +49,7 @@ export default class extends Controller { this._d3GroupMemo = null; this._d3Tooltip = null; this._normalDataPoints = []; + this._secondaryDataPoints = []; this._d3Container.selectAll("*").remove(); } @@ -52,7 +61,14 @@ export default class extends Controller { } _normalizeDataPoints() { - this._normalDataPoints = (this.dataValue.values || []).map((d) => ({ + this._normalDataPoints = this._normalizeSeriesPoints(this.dataValue); + this._secondaryDataPoints = this.hasSecondaryDataValue + ? this._normalizeSeriesPoints(this.secondaryDataValue) + : []; + } + + _normalizeSeriesPoints(seriesData) { + return (seriesData?.values || []).map((d) => ({ date: parseLocalDate(d.date), date_formatted: d.date_formatted, value: d.value, @@ -115,6 +131,10 @@ export default class extends Controller { } _drawChart() { + if (this._secondaryDataPoints.length >= 2) { + this._drawSecondaryTrendline(); + } + this._drawTrendline(); if (this.useLabelsValue) { @@ -142,6 +162,19 @@ export default class extends Controller { .attr("stroke-width", this.strokeWidthValue); } + _drawSecondaryTrendline() { + this._d3Group + .append("path") + .datum(this._secondaryDataPoints) + .attr("fill", "none") + .attr("stroke", this.secondaryStrokeColorValue) + .attr("stroke-dasharray", "6 4") + .attr("d", this._d3SecondaryLine) + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .attr("stroke-width", Math.max(1.5, this.strokeWidthValue - 0.25)); + } + _installTrendlineSplit() { const gradient = this._d3Svg .append("defs") @@ -385,27 +418,66 @@ export default class extends Controller { } _tooltipTemplate(datum) { + const secondaryDatum = this._findSecondaryDatum(datum.date); + + if (!secondaryDatum) { + return ` +
+ ${datum.date_formatted} +
+
+
+
+ ${this._getTrendIcon(datum)} +
+ ${this._extractFormattedValue(datum.trend.current)} +
+ + ${ + datum.trend.value === 0 + ? `` + : ` + + ${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted}) + + ` + } +
+ `; + } + return `
${datum.date_formatted}
-
-
-
- ${this._getTrendIcon(datum)} +
+
+
+
+ ${this._getTrendIcon(datum)} +
+ ${this._primarySeriesLabel}
- ${this._extractFormattedValue(datum.trend.current)} + + ${this._extractFormattedValue(datum.trend.current)} +
- ${ datum.trend.value === 0 - ? `` + ? "" : ` - +
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted}) - +
` } +
+
+
+ ${this._secondarySeriesLabel} +
+ ${this._extractFormattedValue(secondaryDatum.value)} +
`; } @@ -431,6 +503,12 @@ export default class extends Controller { return this._extractNumericValue(datum.value); }; + _findSecondaryDatum(date) { + return this._secondaryDataPoints.find( + (datum) => datum.date?.getTime() === date?.getTime(), + ); + } + _extractNumericValue = (numeric) => { if (typeof numeric === "object" && "amount" in numeric) { return Number(numeric.amount); @@ -505,6 +583,18 @@ export default class extends Controller { return this.dataValue.trend.color; } + get _primarySeriesLabel() { + return this.hasPrimarySeriesLabelValue + ? this.primarySeriesLabelValue + : "Primary"; + } + + get _secondarySeriesLabel() { + return this.hasSecondarySeriesLabelValue + ? this.secondarySeriesLabelValue + : "Secondary"; + } + get _d3Line() { return d3 .line() @@ -512,16 +602,27 @@ export default class extends Controller { .y((d) => this._d3YScale(this._getDatumValue(d))); } + get _d3SecondaryLine() { + return d3 + .line() + .x((d) => this._d3XScale(d.date)) + .y((d) => this._d3YScale(this._getDatumValue(d))); + } + + get _allDataPoints() { + return [...this._normalDataPoints, ...this._secondaryDataPoints]; + } + get _d3XScale() { return d3 .scaleTime() .rangeRound([0, this._d3ContainerWidth]) - .domain(d3.extent(this._normalDataPoints, (d) => d.date)); + .domain(d3.extent(this._allDataPoints, (d) => d.date)); } get _d3YScale() { - const dataMin = d3.min(this._normalDataPoints, this._getDatumValue); - const dataMax = d3.max(this._normalDataPoints, this._getDatumValue); + const dataMin = d3.min(this._allDataPoints, this._getDatumValue); + const dataMax = d3.max(this._allDataPoints, this._getDatumValue); // Handle edge case where all values are the same if (dataMin === dataMax) { diff --git a/app/models/balance.rb b/app/models/balance.rb index 3b6f74ce21c..73db0dbb04d 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -6,6 +6,9 @@ class Balance < ApplicationRecord validates :account, :date, :balance, presence: true validates :flows_factor, inclusion: { in: [ -1, 1 ] } + before_save :calculate_derived_balances + after_initialize :calculate_derived_balances + monetize :balance, :cash_balance, :start_cash_balance, :start_non_cash_balance, :start_balance, :cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows, @@ -28,4 +31,28 @@ def balance_trend def favorable_direction flows_factor == -1 ? "down" : "up" end + + def calculate_derived_balances + raise ArgumentError, "flows_factor is nil" if flows_factor.nil? + raise ArgumentError, "start_cash_balance is nil" if start_cash_balance.nil? + raise ArgumentError, "start_non_cash_balance is nil" if start_non_cash_balance.nil? + raise ArgumentError, "cash_inflows is nil" if cash_inflows.nil? + raise ArgumentError, "cash_outflows is nil" if cash_outflows.nil? + raise ArgumentError, "non_cash_inflows is nil" if non_cash_inflows.nil? + raise ArgumentError, "non_cash_outflows is nil" if non_cash_outflows.nil? + raise ArgumentError, "net_market_flows is nil" if net_market_flows.nil? + raise ArgumentError, "cash_adjustments is nil" if cash_adjustments.nil? + raise ArgumentError, "non_cash_adjustments is nil" if non_cash_adjustments.nil? + # Calculate start_balance + self.start_balance = start_cash_balance + start_non_cash_balance + + # Calculate end_cash_balance + self.end_cash_balance = (start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor)) + cash_adjustments + + # Calculate end_non_cash_balance + self.end_non_cash_balance = ((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor)) + net_market_flows) + non_cash_adjustments + + # Calculate end_balance + self.end_balance = end_cash_balance + end_non_cash_balance + end end diff --git a/app/models/balance/chart_series_builder.rb b/app/models/balance/chart_series_builder.rb index c8c733579e4..9fe20bbd02d 100644 --- a/app/models/balance/chart_series_builder.rb +++ b/app/models/balance/chart_series_builder.rb @@ -65,23 +65,43 @@ def build_series_for(column) ) end - def query_data - @query_data ||= Balance.find_by_sql([ - query, - { - account_ids: account_ids, - target_currency: currency, - start_date: period.start_date, - end_date: period.end_date, - interval: interval, - sign_multiplier: sign_multiplier - } - ]) - rescue => e - Rails.logger.error "Query data error: #{e.message} for accounts #{account_ids}, period #{period.start_date} to #{period.end_date}" - raise + def accounts + @accounts ||= Account.where(id: account_ids).select(:id, :currency, :name) end + def exchange_rates + @exchange_rates ||= begin + ExchangeRate.where(date: (period.start_date - 30.days)..period.end_date) # extend the range so exchange rates are likely to be available + .and(ExchangeRate.where(to_currency: currency)) + .and(ExchangeRate.where(from_currency: accounts.pluck(:currency).uniq)) + .select(:id, :date, :rate, :from_currency, :to_currency) + .group_by { |er| [ er.from_currency, er.to_currency ] } + .transform_values { |rates| rates.sort_by(&:date).reverse } + end + end + + def balances + @balances ||= Balance.where(account_id: account_ids) + .where(date: period.date_range) + .select("*") + .group_by { |b| [ b.account_id, b.date ] } + .transform_values { |balances| balances.sort_by(&:date).last } + end + + def starting_balances + @starting_balances ||= begin + latest_dates = Balance.where("date <= ?", period.start_date) + .where(account_id: account_ids) + .group(:account_id) + .maximum(:date) + + Balance.where(account_id: latest_dates.keys) + .where(date: latest_dates.values) + .select(:account_id, :date, :end_balance, :end_cash_balance, :end_non_cash_balance, :start_balance, :start_cash_balance, :start_non_cash_balance, :flows_factor, :cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows, :cash_adjustments, :non_cash_adjustments) + .group_by { |b| b.account_id } + .transform_values { |balances| balances.sort_by(&:date).last } + end + end # Since the query aggregates the *net* of assets - liabilities, this means that if we're looking at # a single liability account, we'll get a negative set of values. This is not what the user expects # to see. When favorable direction is "down" (i.e. liability, decrease is "good"), we need to invert @@ -90,72 +110,75 @@ def sign_multiplier favorable_direction == "down" ? -1 : 1 end - def query - <<~SQL - WITH dates AS ( - SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date - UNION DISTINCT - SELECT :end_date::date -- Ensure end date is included - ) - SELECT - d.date, - -- Use flows_factor: already handles asset (+1) vs liability (-1) - COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance, - COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance, - -- Holdings only for assets (flows_factor = 1) - COALESCE(SUM( - CASE WHEN last_bal.flows_factor = 1 - THEN last_bal.end_non_cash_balance - ELSE 0 - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ), 0) AS end_holdings_balance, - -- Previous balances - COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance, - COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance, - COALESCE(SUM( - CASE WHEN last_bal.flows_factor = 1 - THEN last_bal.start_non_cash_balance - ELSE 0 - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ), 0) AS start_holdings_balance - FROM dates d - CROSS JOIN accounts - LEFT JOIN LATERAL ( - SELECT b.end_balance, - b.end_cash_balance, - b.end_non_cash_balance, - b.start_balance, - b.start_cash_balance, - b.start_non_cash_balance, - b.flows_factor - FROM balances b - WHERE b.account_id = accounts.id - AND b.currency = accounts.currency - AND b.date <= d.date - ORDER BY b.date DESC - LIMIT 1 - ) last_bal ON TRUE - LEFT JOIN LATERAL ( - SELECT COALESCE( - (SELECT er.rate - FROM exchange_rates er - WHERE er.from_currency = accounts.currency - AND er.to_currency = :target_currency - AND er.date <= d.date - ORDER BY er.date DESC - LIMIT 1), - (SELECT er.rate - FROM exchange_rates er - WHERE er.from_currency = accounts.currency - AND er.to_currency = :target_currency - AND er.date > d.date - ORDER BY er.date ASC - LIMIT 1) - ) AS rate - ) er ON TRUE - WHERE accounts.id = ANY(array[:account_ids]::uuid[]) - GROUP BY d.date - ORDER BY d.date - SQL + def rate_for(from, to, date) + if from == to + return 1 + end + rates = exchange_rates.dig([ from, to ]) || [] + closest_rate = rates.bsearch { |rate| rate.date <= date } + closest_rate&.rate || 1 + end + + def query_data + @query_data ||= begin + result = date_series.map do |date| + OpenStruct.new( + date: date, + end_balance: 0, + end_cash_balance: 0, + end_holdings_balance: 0, + start_balance: 0, + start_cash_balance: 0, + start_holdings_balance: 0 + ) + end + accounts.each do |account| + previous = starting_balances.dig(account.id) + date_series.map.with_index.each do |date, index| + balance = balances.dig([ account.id, date ]) || previous + previous = balance + rate = rate_for(account.currency, currency, date) + if balance + factor = balance.flows_factor * sign_multiplier * rate + result[index].end_balance += balance.end_balance * factor + result[index].end_cash_balance += balance.end_cash_balance * factor + result[index].start_balance += balance.start_balance * factor + result[index].start_cash_balance += balance.start_cash_balance * factor + if balance.flows_factor == 1 + result[index].end_holdings_balance += balance.end_non_cash_balance * factor + result[index].start_holdings_balance += balance.start_non_cash_balance * factor + end + end + end + end + result + end + rescue => e + Rails.logger.error "Query data error: #{e.message} for accounts #{account_ids}, period #{period.start_date} to #{period.end_date}" + raise + end + + def date_series + @date_series ||= begin + dates = [] + current_date = period.start_date + + while current_date <= period.end_date + dates << current_date + current_date = case interval + when "1 day" + current_date + 1.day + when "1 week" + current_date + 1.week + else + # Default to daily if interval is not recognized + current_date + 1.day + end + end + + # Ensure end date is included + dates << period.end_date unless dates.include?(period.end_date) + dates.sort + end end end diff --git a/app/services/api_rate_limiter.rb b/app/services/api_rate_limiter.rb deleted file mode 100644 index d3a771cf5c9..00000000000 --- a/app/services/api_rate_limiter.rb +++ /dev/null @@ -1,95 +0,0 @@ -class ApiRateLimiter - # Rate limit tiers (requests per hour) - RATE_LIMITS = { - standard: 100, - premium: 1000, - enterprise: 10000 - }.freeze - - DEFAULT_TIER = :standard - - def initialize(api_key) - @api_key = api_key - @redis = Redis.new - end - - # Check if the API key has exceeded its rate limit - def rate_limit_exceeded? - current_count >= rate_limit - end - - # Increment the request count for this API key - def increment_request_count! - key = redis_key - current_time = Time.current.to_i - window_start = (current_time / 3600) * 3600 # Hourly window - - @redis.multi do |transaction| - # Use a sliding window with hourly buckets - transaction.hincrby(key, window_start.to_s, 1) - transaction.expire(key, 7200) # Keep data for 2 hours to handle sliding window - end - end - - # Get current request count within the current hour - def current_count - key = redis_key - current_time = Time.current.to_i - window_start = (current_time / 3600) * 3600 - - count = @redis.hget(key, window_start.to_s) - count.to_i - end - - # Get the rate limit for this API key's tier - def rate_limit - tier = determine_tier - RATE_LIMITS[tier] - end - - # Calculate seconds until the rate limit resets - def reset_time - current_time = Time.current.to_i - next_window = ((current_time / 3600) + 1) * 3600 - next_window - current_time - end - - # Get detailed usage information - def usage_info - { - current_count: current_count, - rate_limit: rate_limit, - remaining: [ rate_limit - current_count, 0 ].max, - reset_time: reset_time, - tier: determine_tier - } - end - - # Class method to get usage for an API key without incrementing - def self.usage_for(api_key) - limit(api_key).usage_info - end - - def self.limit(api_key) - if Rails.application.config.app_mode.self_hosted? - # Use NoopApiRateLimiter for self-hosted mode - # This means no rate limiting is applied - NoopApiRateLimiter.new(api_key) - else - new(api_key) - end - end - - private - - def redis_key - "api_rate_limit:#{@api_key.id}" - end - - def determine_tier - # For now, all API keys are standard tier - # This can be extended later to support different tiers based on user subscription - # or API key configuration - DEFAULT_TIER - end -end diff --git a/app/services/noop_api_rate_limiter.rb b/app/services/noop_api_rate_limiter.rb deleted file mode 100644 index 116b6537ed8..00000000000 --- a/app/services/noop_api_rate_limiter.rb +++ /dev/null @@ -1,39 +0,0 @@ -class NoopApiRateLimiter - def initialize(api_key) - @api_key = api_key - end - - def rate_limit_exceeded? - false - end - - def increment_request_count! - # No operation - end - - def current_count - 0 - end - - def rate_limit - Float::INFINITY - end - - def reset_time - 0 - end - - def usage_info - { - current_count: 0, - rate_limit: Float::INFINITY, - remaining: Float::INFINITY, - reset_time: 0, - tier: :noop - } - end - - def self.usage_for(api_key) - new(api_key).usage_info - end -end diff --git a/bin/ci b/bin/ci new file mode 100755 index 00000000000..4137ad5bb07 --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 67ef493142a..9873346d6b9 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -2,6 +2,7 @@ # If running the rails server then create or migrate existing database if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then + ./bin/rails litestream:restore -- --database=storage/production_primary.sqlite3 --if-db-not-exists ./bin/rails db:prepare fi diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 00000000000..dcf59f309ae --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/rubocop b/bin/rubocop index 40330c0ff1c..5a20504716c 100755 --- a/bin/rubocop +++ b/bin/rubocop @@ -2,7 +2,7 @@ require "rubygems" require "bundler/setup" -# explicit rubocop config increases performance slightly while avoiding config confusion. +# Explicit RuboCop config increases performance slightly while avoiding config confusion. ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup index 83098e2485e..39ccc6245d6 100755 --- a/bin/setup +++ b/bin/setup @@ -1,7 +1,6 @@ #!/usr/bin/env ruby require "fileutils" -# path to your application root. APP_ROOT = File.expand_path("..", __dir__) def system!(*args) @@ -14,7 +13,6 @@ FileUtils.chdir APP_ROOT do # Add necessary setup steps to this file. puts "== Installing dependencies ==" - system! "gem install bundler --conservative" system("bundle check") || system!("bundle install") puts "\n== Building design tokens ==" @@ -28,10 +26,14 @@ FileUtils.chdir APP_ROOT do puts "\n== Preparing database ==" system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") puts "\n== Removing old logs and tempfiles ==" system! "bin/rails log:clear tmp:clear" - puts "\n== Restarting application server ==" - system! "bin/rails restart" + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end end diff --git a/compose.example.yml b/compose.example.yml index 108fd6b9a8b..d39b1770681 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -42,11 +42,6 @@ # Or use explicit DNS servers that prefer IPv4 (already configured below). # -x-db-env: &db_env - POSTGRES_USER: ${POSTGRES_USER:-sure_user} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sure_password} - POSTGRES_DB: ${POSTGRES_DB:-sure_production} - x-rails-env: &rails_env <<: *db_env SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13} @@ -55,8 +50,7 @@ x-rails-env: &rails_env RAILS_ASSUME_SSL: "false" DB_HOST: db DB_PORT: 5432 - REDIS_URL: redis://redis:6379/1 - # NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. +# NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this. OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN} services: @@ -77,86 +71,11 @@ services: depends_on: db: condition: service_healthy - redis: - condition: service_healthy - dns: - - 8.8.8.8 - - 1.1.1.1 - networks: - - sure_net - - worker: - image: ghcr.io/we-promise/sure:stable - command: bundle exec sidekiq - volumes: - - app-storage:/rails/storage - restart: unless-stopped - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - dns: - - 8.8.8.8 - - 1.1.1.1 - environment: - <<: *rails_env - networks: - - sure_net - - db: - image: postgres:16 - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - <<: *db_env - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] - interval: 5s - timeout: 5s - retries: 5 - networks: - - sure_net - - backup: - profiles: - - backup - image: prodrigestivill/postgres-backup-local - restart: unless-stopped - volumes: - - /opt/sure-data/backups:/backups # Change this path to your desired backup location on the host machine - environment: - - POSTGRES_HOST=db - - POSTGRES_DB=${POSTGRES_DB:-sure_production} - - POSTGRES_USER=${POSTGRES_USER:-sure_user} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sure_password} # pipelock:ignore - - SCHEDULE=@daily # Runs once a day at midnight - - BACKUP_KEEP_DAYS=7 # Keeps the last 7 days of backups - - BACKUP_KEEP_WEEKS=4 # Keeps 4 weekly backups - - BACKUP_KEEP_MONTHS=6 # Keeps 6 monthly backups - depends_on: - - db - networks: - - sure_net - - redis: - image: redis:latest - restart: unless-stopped - volumes: - - redis-data:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 5s - retries: 5 networks: - sure_net volumes: app-storage: - postgres-data: - redis-data: networks: sure_net: diff --git a/config/application.rb b/config/application.rb index 1269f1aad08..4db15eeb251 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ module Sure class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 + config.load_defaults 8.1 # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. @@ -34,10 +34,12 @@ class Application < Rails::Application config.active_record.encryption = Rails.application.credentials.active_record_encryption end - config.view_component.preview_controller = "LookbooksController" - config.lookbook.preview_display_options = { - theme: [ "light", "dark" ] # available in view as params[:theme] - } + if Rails.env.development? + config.view_component.preview_controller = "LookbooksController" + config.lookbook.preview_display_options = { + theme: [ "light", "dark" ] # available in view as params[:theme] + } + end # Enable Skylight instrumentation for ActiveJob (background workers) config.skylight.probes << "active_job" if defined?(Skylight) @@ -45,11 +47,8 @@ class Application < Rails::Application # Enable Rack::Attack middleware for API rate limiting config.middleware.use Rack::Attack - config.x.ui = ActiveSupport::OrderedOptions.new - default_layout = ENV.fetch("DEFAULT_UI_LAYOUT", "dashboard") - config.x.ui.default_layout = default_layout.in?(%w[dashboard intro]) ? default_layout : "dashboard" - # Handle OmniAuth/OIDC errors gracefully (must be before OmniAuth middleware) - require_relative "../app/middleware/omniauth_error_handler" - config.middleware.use OmniauthErrorHandler + config.mission_control.jobs.http_basic_auth_enabled = false if Rails.env.development? + MissionControl::Jobs.http_basic_auth_user = "jobs" if Rails.env.production? + MissionControl::Jobs.http_basic_auth_password = ENV["MISSION_CONTROL_JOBS_PASSWORD"] || "jobs" if Rails.env.production? end end diff --git a/config/cable.yml b/config/cable.yml index fc5a1eb8efa..7ca155ef873 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,10 +1,21 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. + +default: &default + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day + development: - adapter: async + <<: *default test: adapter: test production: - adapter: redis - url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> - channel_prefix: sure_production + <<: *default diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 00000000000..946125a096f --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,17 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + max_age: <%= 1.hour.to_i %> + max_size: <%= 1.gigabytes %> + namespace: <%= Rails.env %> + +development: &development + <<: *default + database: cache + +test: + <<: *default + +production: &production + <<: *default + database: cache diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 00000000000..795f52c7b20 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,22 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/database.yml b/config/database.yml index 5b249238a33..4f6a4ee9534 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,20 +1,50 @@ default: &default - adapter: postgresql - encoding: unicode - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> - host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %> - port: <%= ENV.fetch("DB_PORT") { "5432" } %> - user: <%= ENV.fetch("POSTGRES_USER") { nil } %> - password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %> + adapter: sqlite3 + timeout: 5000 + default_transaction_mode: IMMEDIATE + pragmas: + foreign_keys: "ON" development: - <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_development" } %> + primary: + <<: *default + database: storage/development_primary.sqlite3 + extensions: + - <%= SQLean::UUID.to_path %> + cache: + <<: *default + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate test: <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_test" } %> + database: storage/test_primary.sqlite3 + extensions: + - <%= SQLean::UUID.to_path %> production: - <<: *default - database: <%= ENV.fetch("POSTGRES_DB") { "sure_production" } %> + primary: + <<: *default + database: storage/production_primary.sqlite3 + extensions: + - <%= SQLean::UUID.to_path %> + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/environments/development.rb b/config/environments/development.rb index 553da47e3e8..1738775c8df 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,9 +3,7 @@ Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. - # In the development environment your application's code is reloaded any time - # it changes. This slows down response time but is perfect for development - # since you don't have to restart the web server when you make code changes. + # Make code changes take effect immediately without server restart. config.enable_reloading = true # Do not eager load code on boot. @@ -14,40 +12,34 @@ # Show full error reports. config.consider_all_requests_local = true - # Enable server timing + # Enable server timing. config.server_timing = true - # Enable/disable caching. By default caching is disabled. - # Run rails dev:cache to toggle caching. + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. if Rails.root.join("tmp/caching-dev.txt").exist? config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true - - config.cache_store = :memory_store - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{2.days.to_i}" - } + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } else config.action_controller.perform_caching = false - - config.cache_store = :null_store end - # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym - config.after_initialize do - ActiveStorage::Current.url_options = { host: "localhost", port: 3000 } - end + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store - # Set Active Storage URL expiration time to 7 days - config.active_storage.urls_expire_in = 7.days + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false config.action_mailer.delivery_method = :letter_opener + + # Make template changes take effect immediately. config.action_mailer.perform_caching = false + # Set localhost to be used by links generated in mailer templates. config.action_mailer.perform_deliveries = true config.action_mailer.default_url_options = { host: "localhost", port: ENV.fetch("PORT") { 3000 } } @@ -55,18 +47,26 @@ # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log - # Raise exceptions for disallowed deprecations. - config.active_support.disallowed_deprecation = :raise - - # Tell Active Support which deprecation messages to disallow. - config.active_support.disallowed_deprecation_warnings = [] - # Raise an error on page load if there are pending migrations. config.active_record.migration_error = :page_load - config.assets.quiet = true + # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + config.solid_queue.logger = ActiveSupport::Logger.new(STDOUT) + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true @@ -77,11 +77,11 @@ # Uncomment if you wish to allow Action Cable access from any origin. # config.action_cable.disable_request_forgery_protection = true - # Raise error when a before_action's only/except options reference missing actions + # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. - config.generators.apply_rubocop_autocorrect_after_generate! + # config.generators.apply_rubocop_autocorrect_after_generate! # Allow connection from any host in development config.hosts = nil diff --git a/config/environments/production.rb b/config/environments/production.rb index fc7120a0a95..10f077a6ed9 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -6,47 +6,32 @@ # Code is not reloaded between requests. config.enable_reloading = false - # Eager load code on boot. This eager loads most of Rails and - # your application in memory, allowing both threaded web servers - # and those relying on copy on write to perform better. - # Rake tasks automatically ignore this option for performance. + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). config.eager_load = true - # Full error reports are disabled and caching is turned on. + # Full error reports are disabled. config.consider_all_requests_local = false - config.action_controller.perform_caching = true - # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment - # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). - # config.require_master_key = true + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true - # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. - # config.public_file_server.enabled = false + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" - # Specifies the header that your server uses for sending files. - # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache - # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX - # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym - # Set Active Storage URL expiration time to 7 days - config.active_storage.urls_expire_in = 7.days - - # Mount Action Cable outside main process or domain. - # config.action_cable.mount_path = nil - # config.action_cable.url = "wss://example.com/cable" - # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_ASSUME_SSL", true)) # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_FORCE_SSL", true)) - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch("RAILS_ASSUME_SSL", true)) + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # Log to Logtail if API key is present, otherwise log to STDOUT base_logger = if ENV["LOGTAIL_API_KEY"].present? && ENV["LOGTAIL_INGESTING_HOST"].present? @@ -55,60 +40,76 @@ ingesting_host: ENV["LOGTAIL_INGESTING_HOST"] ) else - ActiveSupport::Logger.new(STDOUT) - .tap { |logger| logger.formatter = ::Logger::Formatter.new } + ActiveSupport::TaggedLogging.logger(STDOUT) end config.logger = ActiveSupport::TaggedLogging.new(base_logger) - # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] - # "info" includes generic and useful information about system operation, but avoids logging too much - # information to avoid inadvertent exposure of personally identifiable information (PII). If you - # want to log everything, set the level to "debug". + # Change to "debug" to log everything (including potentially personally-identifiable information!). config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - if ENV["REDIS_URL"].present? - config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] } - end + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" - config.action_mailer.perform_caching = false - config.action_mailer.deliver_later_queue_name = :high_priority - config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] } - config.action_mailer.delivery_method = :smtp - config.action_mailer.smtp_settings = { - address: ENV["SMTP_ADDRESS"], - port: ENV["SMTP_PORT"], - user_name: ENV["SMTP_USERNAME"], - password: ENV["SMTP_PASSWORD"], - tls: ENV["SMTP_TLS_ENABLED"] == "true", - openssl_verify_mode: ENV["SMTP_TLS_SKIP_VERIFY"] == "true" ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER, - ca_file: ENV["SSL_CA_FILE"] - } + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + # config.cache_store = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + # config.active_job.queue_adapter = :resque # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). config.i18n.fallbacks = true - # Don't log any deprecations. - config.active_support.report_deprecations = false - # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + # Enable DNS rebinding protection and other `Host` header attacks. # config.hosts = [ # "example.com", # Allow requests from example.com # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` # ] + # # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } - - # set REDIS_URL for Sidekiq to use Redis - config.active_job.queue_adapter = :sidekiq + config.cache_store = :solid_cache_store + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + config.action_mailer.perform_caching = false + config.action_mailer.deliver_later_queue_name = :high_priority + config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] } + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV["SMTP_ADDRESS"], + port: ENV["SMTP_PORT"], + user_name: ENV["SMTP_USERNAME"], + password: ENV["SMTP_PASSWORD"], + tls: ENV["SMTP_TLS_ENABLED"] == "true", + openssl_verify_mode: ENV["SMTP_TLS_SKIP_VERIFY"] == "true" ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER, + ca_file: ENV["SSL_CA_FILE"] + } end diff --git a/config/environments/test.rb b/config/environments/test.rb index 362e5286443..431e076a847 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -17,11 +17,9 @@ # loading is working properly before deploying your code. config.eager_load = ENV["CI"].present? - # Configure public file server for tests with Cache-Control for performance. + # Configure public file server for tests with cache-control for performance. config.public_file_server.enabled = true - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{1.hour.to_i}" - } + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } # Set default sender email for tests ENV["EMAIL_SENDER"] = "hello@example.com" @@ -40,13 +38,14 @@ # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :test - config.action_mailer.perform_caching = false - # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr @@ -64,7 +63,7 @@ # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true - # Raise error when a before_action's only/except options reference missing actions + # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true config.active_record.encryption.primary_key = "test" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index d06e93b0970..a8687e82c3f 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -4,5 +4,6 @@ Rails.application.config.assets.version = "1.0" # Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path Rails.application.config.assets.paths << "app/components" Rails.application.config.importmap.cache_sweepers << Rails.root.join("app/components") diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 834aa21186d..d1642495adc 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -21,6 +21,10 @@ # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } # config.content_security_policy_nonce_directives = %w(script-src style-src) # +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# # # Report violations without enforcing the policy. # # config.content_security_policy_report_only = true # end diff --git a/config/initializers/enable_yjit.rb b/config/initializers/enable_yjit.rb index 8a442216b26..8bdf252ead1 100644 --- a/config/initializers/enable_yjit.rb +++ b/config/initializers/enable_yjit.rb @@ -1,10 +1,6 @@ -# Automatically enable YJIT as of Ruby 3.3, as it brings very -# sizeable performance improvements. - -# If you are deploying to a memory constrained environment -# you may want to delete this file, but otherwise it's free -# performance. -if defined? RubyVM::YJIT.enable +# Enable YJIT only when explicitly requested. It can improve throughput, +# but it also increases memory usage on smaller hosts. +if ENV["ENABLE_YJIT"] == "true" && defined?(RubyVM::YJIT.enable) Rails.application.config.after_initialize do RubyVM::YJIT.enable end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index af9db03123f..f6028485d87 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -4,6 +4,5 @@ # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :openai_access_token, - :client_id, :consumer_key, :snaptrade_user_id, :snaptrade_user_secret + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :openai_access_token ] diff --git a/config/initializers/generator.rb b/config/initializers/generator.rb index 034fab6e565..1111e2319a3 100644 --- a/config/initializers/generator.rb +++ b/config/initializers/generator.rb @@ -1,3 +1,3 @@ Rails.application.config.generators do |g| - g.orm :active_record, primary_key_type: :uuid + g.orm :active_record, primary_key_type: :string end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 8918e6f7699..19be50525fe 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class Rack::Attack + SCANNER_PATH_PATTERNS = [ + %r{(?:^|/)[^/]+\.php\d*\z}i, + %r{\A/+(?:wp-|wordpress|xmlrpc\.php|wp-admin|wp-content|wp-includes|plugin-(?:install|editor)\.php)}i + ].freeze + # Enable Rack::Attack only in production and staging (disable in test/development to avoid rate-limit flakiness) enabled = Rails.env.production? || Rails.env.staging? self.enabled = enabled @@ -58,7 +63,11 @@ class Rack::Attack ] user_agent = request.user_agent - suspicious_user_agents.any? { |pattern| user_agent =~ pattern } if user_agent + suspicious_user_agent = user_agent && suspicious_user_agents.any? { |pattern| user_agent =~ pattern } + normalized_path = request.path.sub(/\A\/+/, "/") + suspicious_path = SCANNER_PATH_PATTERNS.any? { |pattern| normalized_path.match?(pattern) } + + suspicious_user_agent || suspicious_path end # Configure response for throttled requests diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb deleted file mode 100644 index 1338491a4f0..00000000000 --- a/config/initializers/sidekiq.rb +++ /dev/null @@ -1,78 +0,0 @@ -require "sidekiq/web" - -if Rails.env.production? - Sidekiq::Web.use(Rack::Auth::Basic) do |username, password| - configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "sure")) - configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "sure")) - - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) & - ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password) - end -end - -# Configure Redis connection for Sidekiq -# Supports both Redis Sentinel (for HA) and direct Redis URL -redis_config = if ENV["REDIS_SENTINEL_HOSTS"].present? - # Redis Sentinel configuration for high availability - # REDIS_SENTINEL_HOSTS should be comma-separated list: "host1:port1,host2:port2,host3:port3" - sentinels = ENV["REDIS_SENTINEL_HOSTS"].split(",").filter_map do |host_port| - parts = host_port.strip.split(":", 2) - host = parts[0]&.strip - port_str = parts[1]&.strip - - next if host.blank? - - # Parse port with validation, default to 26379 if invalid or missing - port = if port_str.present? - port_int = port_str.to_i - (port_int > 0 && port_int <= 65535) ? port_int : 26379 - else - 26379 - end - - { host: host, port: port } - end - - if sentinels.empty? - Rails.logger.warn("REDIS_SENTINEL_HOSTS is set but no valid sentinel hosts found, falling back to REDIS_URL") - { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } - else - { - url: "redis://#{ENV.fetch('REDIS_SENTINEL_MASTER', 'mymaster')}/0", - sentinels: sentinels, - password: ENV["REDIS_PASSWORD"], - sentinel_username: ENV.fetch("REDIS_SENTINEL_USERNAME", "default"), - sentinel_password: ENV["REDIS_PASSWORD"], - role: :master, - # Recommended timeouts for Sentinel - connect_timeout: 0.2, - read_timeout: 1, - write_timeout: 1, - reconnect_attempts: 3 - } - end -else - # Standard Redis URL configuration (no Sentinel) - { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } -end - -Sidekiq.configure_server do |config| - config.redis = redis_config - - # Initialize auto-sync scheduler when Sidekiq server starts - config.on(:startup) do - AutoSyncScheduler.sync! - Rails.logger.info("[AutoSyncScheduler] Initialized sync_all_accounts cron job") - rescue => e - Rails.logger.error("[AutoSyncScheduler] Failed to initialize: #{e.message}") - end -end - -Sidekiq.configure_client do |config| - config.redis = redis_config -end - -Sidekiq::Cron.configure do |config| - # 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs - config.reschedule_grace_period = 600 -end diff --git a/config/puma.rb b/config/puma.rb index 04022936aa1..ecdcdbd34b3 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -7,9 +7,14 @@ # Puma starts a configurable number of processes (workers) and each process # serves each request in a thread from an internal thread pool. # +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# # The ideal number of threads per worker depends both on how much time the # application spends waiting for IO operations and on how much you wish to -# to prioritize throughput over latency. +# prioritize throughput over latency. # # As a rule of thumb, increasing the number of threads will increase how much # traffic a given process can handle (throughput), but due to CRuby's @@ -22,7 +27,7 @@ # Any libraries that use a connection pool or another resource pool should # be configured to provide at least as many connections as the number of # threads. This includes Active Record's `pool` parameter in `database.yml`. -threads_count = ENV.fetch("RAILS_MAX_THREADS") { 3 } +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) threads threads_count, threads_count if rails_env == "production" @@ -39,9 +44,7 @@ end # Specifies the `port` that Puma will listen on to receive requests; default is 3000. -# The bind host is controlled via the Rails-native `BINDING` env var (set to -# `0.0.0.0` in containers, or `::` for IPv6 dual-stack). See docs/hosting/docker.md. -port ENV.fetch("PORT") { 3000 } +port ENV.fetch("PORT", 3000) # Specifies the `environment` that Puma will run in. environment rails_env @@ -49,6 +52,13 @@ # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] || rails_env == "development" +litestream_enabled = ENV["LITESTREAM_ENABLED"] == "true" && rails_env == "production" +plugin :litestream if litestream_enabled + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. pidfile ENV["PIDFILE"] if ENV["PIDFILE"] if rails_env == "development" diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 00000000000..32ac7ef40b5 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 2 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 00000000000..d1459b4f31c --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,35 @@ +default: &default + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + + import_market_data: + class: "ImportMarketDataJob" + queue: "scheduled" + args: [{ mode: 'full', clear_cache: false }] + schedule: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday + description: "Imports market data daily at 5:00 PM EST (1 hour after market close)" + + +production: + <<: *default + clean_syncs: + schedule: "0 * * * *" # every hour + class: "SyncCleanerJob" + queue: "scheduled" + description: "Cleans up stale syncs" + + run_security_health_checks: + schedule: "0 2 * * 1-5" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday + class: "SecurityHealthCheckJob" + queue: "scheduled" + description: "Runs security health checks to detect issues with security data" + + seed_dump: + class: "SeedDumpJob" + queue: "scheduled" + description: "Runs seed dump manually" + schedule: "every month" + +development: + <<: *default \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 11105021b0e..48116d5c492 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -123,7 +123,7 @@ delete :disable end - mount Lookbook::Engine, at: "/design-system" + mount Lookbook::Engine, at: "/design-system" if Rails.env.development? if Rails.env.development? mount Rswag::Api::Engine => "/api-docs" diff --git a/config/schedule.yml b/config/schedule.yml deleted file mode 100644 index c3903a22921..00000000000 --- a/config/schedule.yml +++ /dev/null @@ -1,44 +0,0 @@ -import_market_data: - cron: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday - class: "ImportMarketDataJob" - queue: "scheduled" - description: "Imports market data daily at 5:00 PM EST (1 hour after market close)" - args: - mode: "full" - clear_cache: false - -clean_syncs: - cron: "0 * * * *" # every hour - class: "SyncCleanerJob" - queue: "scheduled" - description: "Cleans up stale syncs" - -run_security_health_checks: - cron: "0 2 * * 1-5" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday - class: "SecurityHealthCheckJob" - queue: "scheduled" - description: "Runs security health checks to detect issues with security data" - -sync_hourly: - cron: "0 * * * *" # every hour at the top of the hour - class: "SyncHourlyJob" - queue: "scheduled" - description: "Syncs provider items that opt-in to hourly syncing" - -clean_data: - cron: "0 3 * * *" # daily at 3:00 AM - class: "DataCleanerJob" - queue: "scheduled" - description: "Cleans up old data (e.g., expired merchant associations, expired archived exports)" - -clean_inactive_families: - cron: "0 4 * * *" # daily at 4:00 AM - class: "InactiveFamilyCleanerJob" - queue: "scheduled" - description: "Archives and destroys families that expired their trial without subscribing (managed mode only)" - -refresh_demo_family: - cron: "0 5 * * *" # daily at 5:00 AM UTC - class: "DemoFamilyRefreshJob" - queue: "scheduled" - description: "Refreshes demo family data and emails super admins with daily usage summary" diff --git a/config/sidekiq.yml b/config/sidekiq.yml deleted file mode 100644 index 4fce6f00b5e..00000000000 --- a/config/sidekiq.yml +++ /dev/null @@ -1,7 +0,0 @@ -concurrency: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %> -queues: - - [scheduled, 10] # For cron-like jobs (e.g. "daily market data sync") - - [high_priority, 4] - - [medium_priority, 2] - - [low_priority, 1] - - [default, 1] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 00000000000..3aefc381eec --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,23 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.integer "channel_hash", limit: 8, null: false + t.datetime "created_at", null: false + t.binary "payload", limit: 536870912, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 00000000000..2016467a112 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,24 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.integer "byte_size", limit: 4, null: false + t.datetime "created_at", null: false + t.binary "key", limit: 1024, null: false + t.integer "key_hash", limit: 8, null: false + t.binary "value", limit: 536870912, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20250924124734_change_uuid_to_string.rb b/db/migrate/20250924124734_change_uuid_to_string.rb new file mode 100644 index 00000000000..f0b12808152 --- /dev/null +++ b/db/migrate/20250924124734_change_uuid_to_string.rb @@ -0,0 +1,328 @@ +class ChangeUuidToString < ActiveRecord::Migration[7.2] + def change + # remove foreign key constraints + remove_all_foreign_keys + + # accounts + change_column :accounts, :id, :string, null: false + change_column :accounts, :family_id, :string, null: false + change_column :accounts, :accountable_id, :string + change_column :accounts, :import_id, :string + change_column :accounts, :plaid_account_id, :string + change_column :accounts, :simplefin_account_id, :string + + # active_storage_attachments + change_column :active_storage_attachments, :id, :string, null: false + change_column :active_storage_attachments, :record_id, :string, null: false + change_column :active_storage_attachments, :blob_id, :string, null: false + + # active_storage_blobs + change_column :active_storage_blobs, :id, :string, null: false + + # active_storage_variant_records + change_column :active_storage_variant_records, :id, :string, null: false + change_column :active_storage_variant_records, :blob_id, :string, null: false + + # addresses + change_column :addresses, :id, :string, null: false + change_column :addresses, :addressable_id, :string + + # api_keys + change_column :api_keys, :id, :string, null: false + change_column :api_keys, :user_id, :string, null: false + + # balances + change_column :balances, :id, :string, null: false + change_column :balances, :account_id, :string, null: false + + # budget_categories + change_column :budget_categories, :id, :string, null: false + change_column :budget_categories, :budget_id, :string, null: false + change_column :budget_categories, :category_id, :string, null: false + + # budgets + change_column :budgets, :id, :string, null: false + change_column :budgets, :family_id, :string, null: false + + # categories + change_column :categories, :id, :string, null: false + change_column :categories, :family_id, :string, null: false + change_column :categories, :parent_id, :string + + # chats + change_column :chats, :id, :string, null: false + change_column :chats, :user_id, :string, null: false + change_column :chats, :title, :string, null: false + + # credit_cards + change_column :credit_cards, :id, :string, null: false + # cryptos + change_column :cryptos, :id, :string, null: false + + # data_enrichments + change_column :data_enrichments, :id, :string, null: false + change_column :data_enrichments, :enrichable_id, :string, null: false + # depositories + change_column :depositories, :id, :string, null: false + # entries + change_column :entries, :id, :string, null: false + change_column :entries, :account_id, :string, null: false + change_column :entries, :entryable_id, :string + change_column :entries, :import_id, :string + # exchange_rates + change_column :exchange_rates, :id, :string, null: false + # families + change_column :families, :id, :string, null: false + # family_exports + change_column :family_exports, :id, :string, null: false + change_column :family_exports, :family_id, :string, null: false + # holdings + change_column :holdings, :id, :string, null: false + change_column :holdings, :account_id, :string, null: false + change_column :holdings, :security_id, :string, null: false + # impersonation_session_logs + change_column :impersonation_session_logs, :id, :string, null: false + change_column :impersonation_session_logs, :impersonation_session_id, :string, null: false + # impersonation_sessions + change_column :impersonation_sessions, :id, :string, null: false + change_column :impersonation_sessions, :impersonator_id, :string, null: false + change_column :impersonation_sessions, :impersonated_id, :string, null: false + # import_mappings + change_column :import_mappings, :id, :string, null: false + change_column :import_mappings, :import_id, :string, null: false + change_column :import_mappings, :mappable_id, :string + # import_rows + change_column :import_rows, :id, :string, null: false + change_column :import_rows, :import_id, :string, null: false + # imports + change_column :imports, :id, :string, null: false + change_column :imports, :family_id, :string, null: false + change_column :imports, :account_id, :string + # investments + change_column :investments, :id, :string, null: false + # invitations + change_column :invitations, :id, :string, null: false + change_column :invitations, :family_id, :string, null: false + change_column :invitations, :inviter_id, :string, null: false + # invite_codes + change_column :invite_codes, :id, :string, null: false + # loans + change_column :loans, :id, :string, null: false + # merchants + change_column :merchants, :id, :string, null: false + change_column :merchants, :family_id, :string + # messages + change_column :messages, :id, :string, null: false + change_column :messages, :chat_id, :string, null: false + # mobile_devices + change_column :mobile_devices, :id, :string, null: false + change_column :mobile_devices, :user_id, :string, null: false + # oauth_access_grants + # change_column :oauth_access_grants, :id, :string, null: false + # oauth_access_tokens + # change_column :oauth_access_tokens, :id, :string, null: false + # oauth_applications + # change_column :oauth_applications, :id, :string, null: false + change_column :oauth_applications, :owner_id, :string + # other_assets + change_column :other_assets, :id, :string, null: false + # other_liabilities + change_column :other_liabilities, :id, :string, null: false + # plaid_accounts + change_column :plaid_accounts, :id, :string, null: false + change_column :plaid_accounts, :plaid_item_id, :string, null: false + # plaid_items + change_column :plaid_items, :id, :string, null: false + change_column :plaid_items, :family_id, :string, null: false + # properties + change_column :properties, :id, :string, null: false + + # rejected_transfers + change_column :rejected_transfers, :id, :string, null: false + change_column :rejected_transfers, :inflow_transaction_id, :string, null: false + change_column :rejected_transfers, :outflow_transaction_id, :string, null: false + # rule_actions + change_column :rule_actions, :id, :string, null: false + change_column :rule_actions, :rule_id, :string, null: false + # rule_conditions + change_column :rule_conditions, :id, :string, null: false + change_column :rule_conditions, :rule_id, :string + change_column :rule_conditions, :parent_id, :string + # rules + change_column :rules, :id, :string, null: false + change_column :rules, :family_id, :string, null: false + # securities + change_column :securities, :id, :string, null: false + # security_prices + change_column :security_prices, :id, :string, null: false + change_column :security_prices, :security_id, :string + # sessions + change_column :sessions, :id, :string, null: false + change_column :sessions, :user_id, :string, null: false + change_column :sessions, :active_impersonator_session_id, :string + # settings + # change_column :settings, :id, :string, null: false + # simplefin_accounts + change_column :simplefin_accounts, :id, :string, null: false + change_column :simplefin_accounts, :simplefin_item_id, :string, null: false + # simplefin_items + change_column :simplefin_items, :id, :string, null: false + change_column :simplefin_items, :family_id, :string, null: false + # subscriptions + change_column :subscriptions, :id, :string, null: false + change_column :subscriptions, :family_id, :string, null: false + # syncs + change_column :syncs, :id, :string, null: false + change_column :syncs, :syncable_id, :string, null: false + change_column :syncs, :parent_id, :string + # taggings + change_column :taggings, :id, :string, null: false + change_column :taggings, :tag_id, :string, null: false + change_column :taggings, :taggable_id, :string + # tags + change_column :tags, :id, :string, null: false + change_column :tags, :family_id, :string, null: false + # tool_calls + change_column :tool_calls, :id, :string, null: false + change_column :tool_calls, :message_id, :string, null: false + # trades + change_column :trades, :id, :string, null: false + change_column :trades, :security_id, :string, null: false + # transactions + change_column :transactions, :id, :string, null: false + change_column :transactions, :category_id, :string + change_column :transactions, :merchant_id, :string + # transfers + change_column :transfers, :id, :string, null: false + change_column :transfers, :inflow_transaction_id, :string, null: false + change_column :transfers, :outflow_transaction_id, :string, null: false + # users + change_column :users, :id, :string, null: false + change_column :users, :family_id, :string, null: false + change_column :users, :last_viewed_chat_id, :string + + # valuations + change_column :valuations, :id, :string, null: false + # vehicles + change_column :vehicles, :id, :string, null: false + + add_all_foreign_keys + # raise "Not implemented yet, WHAT" + end + + def remove_all_foreign_keys + remove_foreign_key :accounts, :families + remove_foreign_key :accounts, :imports + remove_foreign_key :accounts, :plaid_accounts + remove_foreign_key :accounts, :simplefin_accounts + remove_foreign_key :active_storage_attachments, :active_storage_blobs + remove_foreign_key :active_storage_variant_records, :active_storage_blobs + remove_foreign_key :api_keys, :users + remove_foreign_key :balances, :accounts + remove_foreign_key :budget_categories, :budgets + remove_foreign_key :budget_categories, :categories + remove_foreign_key :budgets, :families + remove_foreign_key :categories, :families + remove_foreign_key :chats, :users + remove_foreign_key :entries, :accounts + remove_foreign_key :entries, :imports + remove_foreign_key :family_exports, :families + remove_foreign_key :holdings, :accounts + remove_foreign_key :holdings, :securities + remove_foreign_key :impersonation_session_logs, :impersonation_sessions + remove_foreign_key :impersonation_sessions, :users, column: :impersonated_id + remove_foreign_key :impersonation_sessions, :users, column: :impersonator_id + remove_foreign_key :import_rows, :imports + remove_foreign_key :imports, :families + remove_foreign_key :invitations, :families + remove_foreign_key :invitations, :users, column: :inviter_id + remove_foreign_key :merchants, :families + remove_foreign_key :messages, :chats + remove_foreign_key :mobile_devices, :users + remove_foreign_key :oauth_access_grants, :oauth_applications, column: :application_id + remove_foreign_key :oauth_access_tokens, :oauth_applications, column: :application_id + remove_foreign_key :plaid_accounts, :plaid_items + remove_foreign_key :plaid_items, :families + remove_foreign_key :rejected_transfers, :transactions, column: :inflow_transaction_id + remove_foreign_key :rejected_transfers, :transactions, column: :outflow_transaction_id + remove_foreign_key :rule_actions, :rules + remove_foreign_key :rule_conditions, :rule_conditions, column: :parent_id + remove_foreign_key :rule_conditions, :rules + remove_foreign_key :rules, :families + remove_foreign_key :security_prices, :securities + remove_foreign_key :sessions, :impersonation_sessions, column: :active_impersonator_session_id + remove_foreign_key :sessions, :users + remove_foreign_key :simplefin_accounts, :simplefin_items + remove_foreign_key :simplefin_items, :families + remove_foreign_key :subscriptions, :families + remove_foreign_key :syncs, :syncs, column: :parent_id + remove_foreign_key :taggings, :tags + remove_foreign_key :tags, :families + remove_foreign_key :tool_calls, :messages + remove_foreign_key :trades, :securities + remove_foreign_key :transactions, :categories + remove_foreign_key :transactions, :merchants + remove_foreign_key :transfers, :transactions, column: :inflow_transaction_id + remove_foreign_key :transfers, :transactions, column: :outflow_transaction_id + remove_foreign_key :users, :chats, column: :last_viewed_chat_id + remove_foreign_key :users, :families + end + + def add_all_foreign_keys + add_foreign_key :accounts, :families + add_foreign_key :accounts, :imports + add_foreign_key :accounts, :plaid_accounts + add_foreign_key :accounts, :simplefin_accounts + add_foreign_key :active_storage_attachments, :active_storage_blobs, column: :blob_id + add_foreign_key :active_storage_variant_records, :active_storage_blobs, column: :blob_id + add_foreign_key :api_keys, :users + add_foreign_key :balances, :accounts, on_delete: :cascade + add_foreign_key :budget_categories, :budgets + add_foreign_key :budget_categories, :categories + add_foreign_key :budgets, :families + add_foreign_key :categories, :families + add_foreign_key :chats, :users + add_foreign_key :entries, :accounts + add_foreign_key :entries, :imports + add_foreign_key :family_exports, :families + add_foreign_key :holdings, :accounts + add_foreign_key :holdings, :securities + add_foreign_key :impersonation_session_logs, :impersonation_sessions + add_foreign_key :impersonation_sessions, :users, column: :impersonated_id + add_foreign_key :impersonation_sessions, :users, column: :impersonator_id + add_foreign_key :import_rows, :imports + add_foreign_key :imports, :families + add_foreign_key :invitations, :families + add_foreign_key :invitations, :users, column: :inviter_id + add_foreign_key :merchants, :families + add_foreign_key :messages, :chats + add_foreign_key :mobile_devices, :users + add_foreign_key :oauth_access_grants, :oauth_applications, column: :application_id + add_foreign_key :oauth_access_tokens, :oauth_applications, column: :application_id + add_foreign_key :plaid_accounts, :plaid_items + add_foreign_key :plaid_items, :families + add_foreign_key :rejected_transfers, :transactions, column: :inflow_transaction_id + add_foreign_key :rejected_transfers, :transactions, column: :outflow_transaction_id + add_foreign_key :rule_actions, :rules + add_foreign_key :rule_conditions, :rule_conditions, column: :parent_id + add_foreign_key :rule_conditions, :rules + add_foreign_key :rules, :families + add_foreign_key :security_prices, :securities + add_foreign_key :sessions, :impersonation_sessions, column: :active_impersonator_session_id + add_foreign_key :sessions, :users + add_foreign_key :simplefin_accounts, :simplefin_items + add_foreign_key :simplefin_items, :families + add_foreign_key :subscriptions, :families + add_foreign_key :syncs, :syncs, column: :parent_id + add_foreign_key :taggings, :tags + add_foreign_key :tags, :families + add_foreign_key :tool_calls, :messages + add_foreign_key :trades, :securities + add_foreign_key :transactions, :categories, on_delete: :nullify + add_foreign_key :transactions, :merchants + add_foreign_key :transfers, :transactions, column: :inflow_transaction_id, on_delete: :cascade + add_foreign_key :transfers, :transactions, column: :outflow_transaction_id, on_delete: :cascade + add_foreign_key :users, :chats, column: :last_viewed_chat_id + add_foreign_key :users, :families + end +end diff --git a/db/migrate/20250924142630_change_json_b_to_json.rb b/db/migrate/20250924142630_change_json_b_to_json.rb new file mode 100644 index 00000000000..0a658b2a646 --- /dev/null +++ b/db/migrate/20250924142630_change_json_b_to_json.rb @@ -0,0 +1,54 @@ +class ChangeJsonBToJson < ActiveRecord::Migration[7.2] + def change + change_column :accounts, :locked_attributes, :json + change_column :chats, :error, :json + change_column :credit_cards, :locked_attributes, :json + change_column :cryptos, :locked_attributes, :json + + change_column :data_enrichments, :value, :json + change_column :data_enrichments, :metadata, :json + + change_column :depositories, :locked_attributes, :json + + change_column :entries, :locked_attributes, :json + + change_column :imports, :column_mappings, :json + + change_column :investments, :locked_attributes, :json + + change_column :loans, :locked_attributes, :json + + change_column :other_assets, :locked_attributes, :json + + change_column :other_liabilities, :locked_attributes, :json + + change_column :plaid_accounts, :raw_payload, :json + change_column :plaid_accounts, :raw_transactions_payload, :json + change_column :plaid_accounts, :raw_investments_payload, :json + change_column :plaid_accounts, :raw_liabilities_payload, :json + + change_column :plaid_items, :raw_payload, :json + change_column :plaid_items, :raw_institution_payload, :json + + change_column :properties, :locked_attributes, :json + + change_column :sessions, :prev_transaction_page_params, :json + change_column :sessions, :data, :json + + change_column :simplefin_accounts, :raw_payload, :json + change_column :simplefin_accounts, :raw_transactions_payload, :json + change_column :simplefin_accounts, :extra, :json + change_column :simplefin_accounts, :org_data, :json + + change_column :simplefin_items, :raw_payload, :json + change_column :simplefin_items, :raw_institution_payload, :json + + change_column :syncs, :data, :json + + change_column :tool_calls, :function_arguments, :json + change_column :tool_calls, :function_result, :json + + change_column :trades, :locked_attributes, :json + change_column :transactions, :locked_attributes, :json + end +end diff --git a/db/migrate/20250924190901_convert_accounts_classification_to_regular_column.rb b/db/migrate/20250924190901_convert_accounts_classification_to_regular_column.rb new file mode 100644 index 00000000000..bdf38669fb3 --- /dev/null +++ b/db/migrate/20250924190901_convert_accounts_classification_to_regular_column.rb @@ -0,0 +1,19 @@ +class ConvertAccountsClassificationToRegularColumn < ActiveRecord::Migration[7.0] + def change + # Remove the virtual column + remove_column :accounts, :classification, :string + + # Add a regular string column + add_column :accounts, :classification, :string + + # Populate the classification column based on accountable_type + execute <<-SQL + UPDATE accounts#{' '} + SET classification = CASE + WHEN accountable_type IN ('Loan', 'CreditCard', 'OtherLiability') + THEN 'liability' + ELSE 'asset' + END + SQL + end +end diff --git a/db/migrate/20250924191112_convert_account_balances_virtual_columns_to_regular_columns.rb b/db/migrate/20250924191112_convert_account_balances_virtual_columns_to_regular_columns.rb new file mode 100644 index 00000000000..c02d4811e53 --- /dev/null +++ b/db/migrate/20250924191112_convert_account_balances_virtual_columns_to_regular_columns.rb @@ -0,0 +1,25 @@ +class ConvertAccountBalancesVirtualColumnsToRegularColumns < ActiveRecord::Migration[7.0] + def change + # Remove all virtual columns + remove_column :balances, :start_balance, :decimal + remove_column :balances, :end_cash_balance, :decimal + remove_column :balances, :end_non_cash_balance, :decimal + remove_column :balances, :end_balance, :decimal + + # Add regular decimal columns + add_column :balances, :start_balance, :decimal, precision: 19, scale: 4 + add_column :balances, :end_cash_balance, :decimal, precision: 19, scale: 4 + add_column :balances, :end_non_cash_balance, :decimal, precision: 19, scale: 4 + add_column :balances, :end_balance, :decimal, precision: 19, scale: 4 + + # Populate the columns based on the original virtual column logic + execute <<-SQL + UPDATE balances#{' '} + SET#{' '} + start_balance = (start_cash_balance + start_non_cash_balance), + end_cash_balance = ((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments), + end_non_cash_balance = (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments), + end_balance = (((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)) + SQL + end +end diff --git a/db/migrate/20250924194505_remove_functional_and_partial_indexes.rb b/db/migrate/20250924194505_remove_functional_and_partial_indexes.rb new file mode 100644 index 00000000000..0eb4753956a --- /dev/null +++ b/db/migrate/20250924194505_remove_functional_and_partial_indexes.rb @@ -0,0 +1,25 @@ +class RemoveFunctionalAndPartialIndexes < ActiveRecord::Migration[7.0] + def change + # Remove functional index on entries.name + remove_index :entries, name: "index_entries_on_lower_name" + + # Remove partial indexes on merchants + remove_index :merchants, name: "index_merchants_on_family_id_and_name" + remove_index :merchants, name: "index_merchants_on_source_and_name" + + # Remove partial index on users.otp_secret + remove_index :users, name: "index_users_on_otp_secret" + + # Add regular indexes to replace them + + # Regular index on entries.name (for case-insensitive searches, handle in application) + add_index :entries, :name, name: "index_entries_on_name" + + # Regular indexes on merchants (remove unique constraint since we can't enforce it per type) + add_index :merchants, [ :family_id, :name ], name: "index_merchants_on_family_id_and_name" + add_index :merchants, [ :source, :name ], name: "index_merchants_on_source_and_name" + + # Regular index on users.otp_secret (remove unique constraint since we can't enforce it for non-null only) + add_index :users, :otp_secret, name: "index_users_on_otp_secret" + end +end diff --git a/db/migrate/20250924215505_convert_billed_products_array_to_json.rb b/db/migrate/20250924215505_convert_billed_products_array_to_json.rb new file mode 100644 index 00000000000..2ff85d1909e --- /dev/null +++ b/db/migrate/20250924215505_convert_billed_products_array_to_json.rb @@ -0,0 +1,30 @@ +class ConvertBilledProductsArrayToJson < ActiveRecord::Migration[7.0] + def change + # Remove PostgreSQL array columns + remove_column :plaid_items, :available_products, :string, array: true + remove_column :plaid_items, :billed_products, :string, array: true + remove_column :users, :otp_backup_codes, :string, array: true + remove_column :users, :goals, :text, array: true + + # Add JSON columns with default empty array + add_column :plaid_items, :available_products, :json, default: [] + add_column :plaid_items, :billed_products, :json, default: [] + add_column :users, :otp_backup_codes, :json, default: [] + add_column :users, :goals, :json, default: [] + + # Convert JSONB to JSON for database compatibility + remove_column :valuations, :locked_attributes, :jsonb + remove_column :vehicles, :locked_attributes, :jsonb + + add_column :valuations, :locked_attributes, :json, default: {} + add_column :vehicles, :locked_attributes, :json, default: {} + + # Remove PostgreSQL-specific functional index + remove_index :securities, name: "index_securities_on_ticker_and_exchange_operating_mic_unique" + + # Add regular index instead + add_index :securities, [ :ticker, :exchange_operating_mic ], + name: "index_securities_on_ticker_and_exchange_operating_mic_unique", + unique: true + end +end diff --git a/db/migrate/20250927190818_remove_uuid_default_value.rb b/db/migrate/20250927190818_remove_uuid_default_value.rb new file mode 100644 index 00000000000..75963b78447 --- /dev/null +++ b/db/migrate/20250927190818_remove_uuid_default_value.rb @@ -0,0 +1,88 @@ + +class RemoveUuidDefaultValue < ActiveRecord::Migration[7.2] + def up + # Remove PostgreSQL-specific UUID defaults for SQLite compatibility + # SQLite doesn't support gen_random_uuid(), so we'll handle UUIDs in Ruby + + # List of tables that need UUID default removal + tables_with_uuid_defaults = [ + 'accounts', + 'active_storage_attachments', + 'active_storage_blobs', + 'active_storage_variant_records', + 'addresses', + 'api_keys', + 'balances', + 'budget_categories', + 'budgets', + 'categories', + 'chats', + 'credit_cards', + 'cryptos', + 'data_enrichments', + 'depositories', + 'entries', + 'exchange_rates', + 'families', + 'family_exports', + 'holdings', + 'impersonation_session_logs', + 'impersonation_sessions', + 'import_mappings', + 'import_rows', + 'imports', + 'investments', + 'invitations', + 'invite_codes', + 'loans', + 'merchants', + 'messages', + 'mobile_devices', + # 'oauth_access_grants', + # 'oauth_access_tokens', + # 'oauth_applications', + 'other_assets', + 'other_liabilities', + 'plaid_accounts', + 'plaid_items', + 'properties', + 'rejected_transfers', + 'rule_actions', + 'rule_conditions', + 'rules', + 'securities', + 'security_prices', + 'sessions', + # 'settings', + 'simplefin_accounts', + 'simplefin_items', + 'subscriptions', + 'syncs', + 'taggings', + 'tags', + 'tool_calls', + 'trades', + 'transactions', + 'transfers', + 'users', + 'valuations', + 'vehicles' + ] + + tables_with_uuid_defaults.each do |table_name| + if table_exists?(table_name) + # Remove the default UUID generation for SQLite compatibility + change_column_default table_name, :id, nil + puts "Removed UUID default from #{table_name}" + else + raise ActiveRecord::MigrationError, "Table #{table_name} does not exist" + end + end + end + + def down + # This migration is not reversible as we can't restore PostgreSQL-specific defaults + # in a SQLite environment + # raise ActiveRecord::IrreversibleMigration, "Cannot restore PostgreSQL UUID defaults in SQLite" + end +end diff --git a/db/migrate/20250928213010_add_uuid_defaults_to_active_storage_tables.rb b/db/migrate/20250928213010_add_uuid_defaults_to_active_storage_tables.rb new file mode 100644 index 00000000000..d80a63c1272 --- /dev/null +++ b/db/migrate/20250928213010_add_uuid_defaults_to_active_storage_tables.rb @@ -0,0 +1,8 @@ +class AddUuidDefaultsToActiveStorageTables < ActiveRecord::Migration[7.2] + def change + # Add UUID default values for Active Storage tables primary keys + change_column_default :active_storage_attachments, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_blobs, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_variant_records, :id, -> { "gen_random_uuid()" } + end +end diff --git a/db/migrate/20250928220425_add_uuid_default_to_categories_table.rb b/db/migrate/20250928220425_add_uuid_default_to_categories_table.rb new file mode 100644 index 00000000000..592b3fe6a25 --- /dev/null +++ b/db/migrate/20250928220425_add_uuid_default_to_categories_table.rb @@ -0,0 +1,6 @@ +class AddUuidDefaultToCategoriesTable < ActiveRecord::Migration[7.2] + def change + # Add UUID default value for categories table primary key + change_column_default :categories, :id, -> { "gen_random_uuid()" } + end +end diff --git a/db/migrate/20250929215928_add_uuid_defaults_to_all_tables.rb b/db/migrate/20250929215928_add_uuid_defaults_to_all_tables.rb new file mode 100644 index 00000000000..1abe72d610a --- /dev/null +++ b/db/migrate/20250929215928_add_uuid_defaults_to_all_tables.rb @@ -0,0 +1,77 @@ +class AddUuidDefaultsToAllTables < ActiveRecord::Migration[7.2] + def change + # Remove PostgreSQL-specific UUID defaults for SQLite compatibility + # SQLite doesn't support gen_random_uuid(), so we'll handle UUIDs in Ruby + + # List of tables that need UUID default removal + tables_with_uuid_defaults = [ + 'accounts', + 'addresses', + 'api_keys', + 'balances', + 'budget_categories', + 'budgets', + 'chats', + 'credit_cards', + 'cryptos', + 'data_enrichments', + 'depositories', + 'entries', + 'exchange_rates', + 'families', + 'family_exports', + 'holdings', + 'impersonation_session_logs', + 'impersonation_sessions', + 'import_mappings', + 'import_rows', + 'imports', + 'investments', + 'invitations', + 'invite_codes', + 'loans', + 'merchants', + 'messages', + 'mobile_devices', + # 'oauth_access_grants', + # 'oauth_access_tokens', + # 'oauth_applications', + 'other_assets', + 'other_liabilities', + 'plaid_accounts', + 'plaid_items', + 'properties', + 'rejected_transfers', + 'rule_actions', + 'rule_conditions', + 'rules', + 'securities', + 'security_prices', + 'sessions', + # 'settings', + 'simplefin_accounts', + 'simplefin_items', + 'subscriptions', + 'syncs', + 'taggings', + 'tags', + 'tool_calls', + 'trades', + 'transactions', + 'transfers', + 'users', + 'valuations', + 'vehicles' + ] + + tables_with_uuid_defaults.each do |table_name| + if table_exists?(table_name) + # Remove the default UUID generation for SQLite compatibility + change_column_default table_name, :id, nil + puts "Removed UUID default from #{table_name}" + else + raise ActiveRecord::MigrationError, "Table #{table_name} does not exist" + end + end + end +end diff --git a/db/migrate/20250930191642_change_default_id_to_ulid.rb b/db/migrate/20250930191642_change_default_id_to_ulid.rb new file mode 100644 index 00000000000..20d7a0da3aa --- /dev/null +++ b/db/migrate/20250930191642_change_default_id_to_ulid.rb @@ -0,0 +1,17 @@ +class ChangeDefaultIdToUlid < ActiveRecord::Migration[7.2] + def up + # Change default value from gen_random_uuid() to ulid() for tables that use it + change_column_default :active_storage_attachments, :id, -> { "ulid()" } + change_column_default :active_storage_blobs, :id, -> { "ulid()" } + change_column_default :active_storage_variant_records, :id, -> { "ulid()" } + change_column_default :categories, :id, -> { "ulid()" } + end + + def down + # Revert back to gen_random_uuid() if needed + change_column_default :active_storage_attachments, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_blobs, :id, -> { "gen_random_uuid()" } + change_column_default :active_storage_variant_records, :id, -> { "gen_random_uuid()" } + change_column_default :categories, :id, -> { "gen_random_uuid()" } + end +end diff --git a/db/migrate/20251003011536_add_service_name_to_active_storage_blobs.active_storage.rb b/db/migrate/20251003011536_add_service_name_to_active_storage_blobs.active_storage.rb new file mode 100644 index 00000000000..a15c6ce8e58 --- /dev/null +++ b/db/migrate/20251003011536_add_service_name_to_active_storage_blobs.active_storage.rb @@ -0,0 +1,22 @@ +# This migration comes from active_storage (originally 20190112182829) +class AddServiceNameToActiveStorageBlobs < ActiveRecord::Migration[6.0] + def up + return unless table_exists?(:active_storage_blobs) + + unless column_exists?(:active_storage_blobs, :service_name) + add_column :active_storage_blobs, :service_name, :string + + if configured_service = ActiveStorage::Blob.service.name + ActiveStorage::Blob.unscoped.update_all(service_name: configured_service) + end + + change_column :active_storage_blobs, :service_name, :string, null: false + end + end + + def down + return unless table_exists?(:active_storage_blobs) + + remove_column :active_storage_blobs, :service_name + end +end diff --git a/db/migrate/20251003011537_create_active_storage_variant_records.active_storage.rb b/db/migrate/20251003011537_create_active_storage_variant_records.active_storage.rb new file mode 100644 index 00000000000..4806b10e8e6 --- /dev/null +++ b/db/migrate/20251003011537_create_active_storage_variant_records.active_storage.rb @@ -0,0 +1,27 @@ +# This migration comes from active_storage (originally 20191206030411) +class CreateActiveStorageVariantRecords < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + # Use Active Record's configured type for primary key + create_table :active_storage_variant_records, id: primary_key_type, if_not_exists: true do |t| + t.belongs_to :blob, null: false, index: false, type: blobs_primary_key_type + t.string :variation_digest, null: false + + t.index %i[blob_id variation_digest], name: "index_active_storage_variant_records_uniqueness", unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_key_type + config = Rails.configuration.generators + config.options[config.orm][:primary_key_type] || :primary_key + end + + def blobs_primary_key_type + pkey_name = connection.primary_key(:active_storage_blobs) + pkey_column = connection.columns(:active_storage_blobs).find { |c| c.name == pkey_name } + pkey_column.bigint? ? :bigint : pkey_column.type + end +end diff --git a/db/migrate/20251003011538_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb b/db/migrate/20251003011538_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb new file mode 100644 index 00000000000..93c8b85ade5 --- /dev/null +++ b/db/migrate/20251003011538_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb @@ -0,0 +1,8 @@ +# This migration comes from active_storage (originally 20211119233751) +class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + change_column_null(:active_storage_blobs, :checksum, true) + end +end diff --git a/db/migrate/20251008015408_update_id_defaults_to_gen_random_uuid.rb b/db/migrate/20251008015408_update_id_defaults_to_gen_random_uuid.rb new file mode 100644 index 00000000000..312679b9d12 --- /dev/null +++ b/db/migrate/20251008015408_update_id_defaults_to_gen_random_uuid.rb @@ -0,0 +1,27 @@ +class UpdateIdDefaultsToGenRandomUuid < ActiveRecord::Migration[8.0] + def change + # List of all tables with string ID columns that need gen_random_uuid() default + tables_with_string_ids = [ + 'accounts', 'addresses', 'api_keys', 'balances', 'budget_categories', 'budgets', + 'chats', 'credit_cards', 'cryptos', 'data_enrichments', 'depositories', 'entries', + 'exchange_rates', 'families', 'family_exports', 'holdings', 'impersonation_session_logs', + 'impersonation_sessions', 'import_mappings', 'import_rows', 'imports', 'investments', + 'invitations', 'invite_codes', 'loans', 'merchants', 'messages', 'mobile_devices', + 'other_assets', 'other_liabilities', 'plaid_accounts', 'plaid_items', 'properties', + 'rejected_transfers', 'rule_actions', 'rule_conditions', 'rules', 'securities', + 'security_prices', 'sessions', 'simplefin_accounts', 'simplefin_items', 'subscriptions', + 'syncs', 'taggings', 'tags', 'tool_calls', 'trades', 'transactions', 'transfers', + 'users', 'valuations', 'vehicles', + 'active_storage_attachments', 'active_storage_blobs', 'active_storage_variant_records', + 'categories' + ] + + tables_with_string_ids.each do |table_name| + # Check if table exists and has an id column + if table_exists?(table_name) && column_exists?(table_name, :id) + # Update the id column to use gen_random_uuid() as default + change_column_default table_name, :id, -> { "gen_random_uuid()" } + end + end + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 00000000000..f56798cab3a --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,141 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.string "concurrency_key", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.bigint "process_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "active_job_id" + t.text "arguments" + t.string "class_name", null: false + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.text "metadata" + t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.datetime "run_at", null: false + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.text "arguments" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false + t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false + t.boolean "static", default: true, null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb index 1c11eea005e..1437cb2bac8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,60 +10,24 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_03_180000) do - # These are extensions that must be enabled in order to support this database - enable_extension "pgcrypto" - enable_extension "plpgsql" - - # Custom types defined in this database. - # Note that some types may not work with other database engines. Be careful if changing database. - create_enum "account_status", ["ok", "syncing", "error"] - - create_table "account_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.string "provider_type", null: false - t.uuid "provider_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_and_provider_type", unique: true - t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true - end - - create_table "account_shares", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.uuid "user_id", null: false - t.string "permission", default: "read_only", null: false - t.boolean "include_in_finances", default: true, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id", "user_id"], name: "index_account_shares_on_account_id_and_user_id", unique: true - t.index ["account_id"], name: "index_account_shares_on_account_id" - t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances" - t.index ["user_id"], name: "index_account_shares_on_user_id" - t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission" - end - - create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "subtype" - t.uuid "family_id", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false +ActiveRecord::Schema[8.1].define(version: 2025_10_08_015408) do + create_table "accounts", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "accountable_id" t.string "accountable_type" - t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4 - t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true - t.uuid "import_id" - t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "locked_attributes", default: {} + t.string "classification" + t.datetime "created_at", null: false + t.string "currency" + t.string "family_id", null: false + t.string "import_id" + t.json "locked_attributes", default: {} + t.string "name" + t.string "plaid_account_id" + t.string "simplefin_account_id" t.string "status", default: "active" - t.uuid "simplefin_account_id" - t.string "institution_name" - t.string "institution_domain" - t.text "notes" - t.uuid "owner_id" + t.string "subtype" + t.datetime "updated_at", null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["currency"], name: "index_accounts_on_currency" @@ -79,569 +43,254 @@ t.index ["status"], name: "index_accounts_on_status" end - create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "active_storage_attachments", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "blob_id", null: false + t.datetime "created_at", null: false t.string "name", null: false + t.string "record_id", null: false t.string "record_type", null: false - t.uuid "record_id", null: false - t.uuid "blob_id", null: false - t.datetime "created_at", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end - create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false + create_table "active_storage_blobs", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end - create_table "active_storage_variant_records", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "blob_id", null: false + create_table "active_storage_variant_records", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "blob_id", null: false t.string "variation_digest", null: false t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end - create_table "addresses", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "addresses", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "addressable_id" t.string "addressable_type" - t.uuid "addressable_id" + t.string "country" + t.string "county" + t.datetime "created_at", null: false t.string "line1" t.string "line2" - t.string "county" t.string "locality" + t.integer "postal_code" t.string "region" - t.string "country" - t.string "postal_code" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end - create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" - t.uuid "user_id", null: false - t.json "scopes" - t.datetime "last_used_at" - t.datetime "expires_at" - t.datetime "revoked_at" + create_table "api_keys", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "display_key", null: false + t.datetime "expires_at" + t.datetime "last_used_at" + t.string "name" + t.datetime "revoked_at" + t.json "scopes" t.string "source", default: "web" + t.datetime "updated_at", null: false + t.string "user_id", null: false t.index ["display_key"], name: "index_api_keys_on_display_key", unique: true t.index ["revoked_at"], name: "index_api_keys_on_revoked_at" t.index ["user_id", "source"], name: "index_api_keys_on_user_id_and_source" t.index ["user_id"], name: "index_api_keys_on_user_id" end - create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "email", null: false - t.string "family_name" - t.string "download_token_digest", null: false - t.datetime "expires_at", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true - t.index ["expires_at"], name: "index_archived_exports_on_expires_at" - end - - create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.date "date", null: false + create_table "balances", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_id", null: false t.decimal "balance", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false + t.date "date", null: false + t.decimal "end_balance", precision: 19, scale: 4 + t.decimal "end_cash_balance", precision: 19, scale: 4 + t.decimal "end_non_cash_balance", precision: 19, scale: 4 + t.integer "flows_factor", default: 1, null: false t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false - t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false - t.integer "flows_factor", default: 1, null: false - t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true - t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true - t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true - t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_balance", precision: 19, scale: 4 + t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.datetime "updated_at", null: false t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" end - create_table "binance_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "binance_item_id", null: false - t.string "name" - t.string "account_type" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.jsonb "extra", default: {}, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_type"], name: "index_binance_accounts_on_account_type" - t.index ["binance_item_id", "account_type"], name: "index_binance_accounts_on_item_and_type", unique: true - t.index ["binance_item_id"], name: "index_binance_accounts_on_binance_item_id" - end - - create_table "binance_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.text "api_key" - t.text "api_secret" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_binance_items_on_family_id" - t.index ["status"], name: "index_binance_items_on_status" - end - - create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "budget_id", null: false - t.uuid "category_id", null: false + create_table "budget_categories", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "budget_id", null: false t.decimal "budgeted_spending", precision: 19, scale: 4, null: false - t.string "currency", null: false + t.string "category_id", null: false t.datetime "created_at", null: false + t.string "currency", null: false t.datetime "updated_at", null: false t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true t.index ["budget_id"], name: "index_budget_categories_on_budget_id" t.index ["category_id"], name: "index_budget_categories_on_category_id" end - create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.date "start_date", null: false - t.date "end_date", null: false + create_table "budgets", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.decimal "budgeted_spending", precision: 19, scale: 4 - t.decimal "expected_income", precision: 19, scale: 4 - t.string "currency", null: false t.datetime "created_at", null: false + t.string "currency", null: false + t.date "end_date", null: false + t.decimal "expected_income", precision: 19, scale: 4 + t.string "family_id", null: false + t.date "start_date", null: false t.datetime "updated_at", null: false t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true t.index ["family_id"], name: "index_budgets_on_family_id" end - create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false + create_table "categories", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "classification", default: "expense", null: false t.string "color", default: "#6172F3", null: false - t.uuid "family_id", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "parent_id" + t.string "family_id", null: false t.string "lucide_icon", default: "shapes", null: false - t.string "classification_unused", default: "expense", null: false + t.string "name", null: false + t.string "parent_id" + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_categories_on_family_id" end - create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "title", null: false + create_table "chats", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.json "error" t.string "instructions" - t.jsonb "error" t.string "latest_assistant_response_id" - t.datetime "created_at", null: false + t.string "title", null: false t.datetime "updated_at", null: false + t.string "user_id", null: false t.index ["user_id"], name: "index_chats_on_user_id" end - create_table "coinbase_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinbase_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_coinbase_accounts_on_account_id" - t.index ["coinbase_item_id", "account_id"], name: "index_coinbase_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" - t.index ["coinbase_item_id"], name: "index_coinbase_accounts_on_coinbase_item_id" - end - - create_table "coinbase_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "api_key" - t.text "api_secret" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_coinbase_items_on_family_id" - t.index ["status"], name: "index_coinbase_items_on_status" - end - - create_table "coinstats_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "coinstats_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "wallet_address" - t.index ["coinstats_item_id", "account_id", "wallet_address"], name: "index_coinstats_accounts_on_item_account_and_wallet", unique: true - t.index ["coinstats_item_id"], name: "index_coinstats_accounts_on_coinstats_item_id" - end - - create_table "coinstats_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "api_key", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "exchange_portfolio_id" - t.string "exchange_connection_id" - t.index ["exchange_connection_id"], name: "index_coinstats_items_on_exchange_connection_id" - t.index ["family_id", "exchange_portfolio_id"], name: "index_coinstats_items_on_family_id_and_exchange_portfolio_id", unique: true, where: "(exchange_portfolio_id IS NOT NULL)" - t.index ["family_id"], name: "index_coinstats_items_on_family_id" - t.index ["status"], name: "index_coinstats_items_on_status" - end - - create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.decimal "available_credit", precision: 10, scale: 2 - t.decimal "minimum_payment", precision: 10, scale: 2 + create_table "credit_cards", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "annual_fee", precision: 10, scale: 2 t.decimal "apr", precision: 10, scale: 2 + t.decimal "available_credit", precision: 10, scale: 2 + t.datetime "created_at", null: false t.date "expiration_date" - t.decimal "annual_fee", precision: 10, scale: 2 - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} + t.decimal "minimum_payment", precision: 10, scale: 2 t.string "subtype" + t.datetime "updated_at", null: false end - create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "cryptos", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" - t.string "tax_treatment", default: "taxable", null: false + t.datetime "updated_at", null: false end - create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "enrichable_type", null: false - t.uuid "enrichable_id", null: false - t.string "source" + create_table "data_enrichments", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "attribute_name" - t.jsonb "value" - t.jsonb "metadata" t.datetime "created_at", null: false + t.string "enrichable_id", null: false + t.string "enrichable_type", null: false + t.json "metadata" + t.string "source" t.datetime "updated_at", null: false + t.json "value" t.index ["enrichable_id", "enrichable_type", "source", "attribute_name"], name: "idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08", unique: true t.index ["enrichable_type", "enrichable_id"], name: "index_data_enrichments_on_enrichable" end - create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "depositories", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" - end - - create_table "enable_banking_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "enable_banking_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.string "iban" - t.string "uid" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "product" - t.decimal "credit_limit", precision: 19, scale: 4 - t.jsonb "identification_hashes", default: [] - t.index ["account_id"], name: "index_enable_banking_accounts_on_account_id" - t.index ["enable_banking_item_id"], name: "index_enable_banking_accounts_on_enable_banking_item_id" - t.index ["identification_hashes"], name: "index_enable_banking_accounts_on_identification_hashes", using: :gin - end - - create_table "enable_banking_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.date "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "country_code" - t.string "application_id" - t.text "client_certificate" - t.string "session_id" - t.datetime "session_expires_at" - t.string "aspsp_name" - t.string "aspsp_id" - t.string "authorization_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.jsonb "aspsp_required_psu_headers", default: [] - t.integer "aspsp_maximum_consent_validity" - t.string "aspsp_auth_approach" - t.jsonb "aspsp_psu_types", default: [] - t.string "last_psu_ip" - t.string "psu_type" - t.index ["family_id"], name: "index_enable_banking_items_on_family_id" - t.index ["status"], name: "index_enable_banking_items_on_status" end - create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.string "entryable_type" - t.uuid "entryable_id" + create_table "entries", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_id", null: false t.decimal "amount", precision: 19, scale: 4, null: false + t.datetime "created_at", null: false t.string "currency" t.date "date" + t.string "entryable_id" + t.string "entryable_type" + t.boolean "excluded", default: false + t.string "import_id" + t.json "locked_attributes", default: {} t.string "name", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "import_id" t.text "notes" - t.boolean "excluded", default: false t.string "plaid_id" - t.jsonb "locked_attributes", default: {} - t.string "external_id" - t.string "source" - t.boolean "user_modified", default: false, null: false - t.boolean "import_locked", default: false, null: false - t.uuid "parent_entry_id" - t.index "lower((name)::text)", name: "index_entries_on_lower_name" + t.datetime "updated_at", null: false t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date" t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))" t.index ["account_id"], name: "index_entries_on_account_id" + t.index ["amount"], name: "index_entries_on_amount" t.index ["date"], name: "index_entries_on_date" + t.index ["entryable_id", "entryable_type"], name: "index_entries_on_entryable" t.index ["entryable_type"], name: "index_entries_on_entryable_type" + t.index ["excluded"], name: "index_entries_on_excluded" t.index ["import_id"], name: "index_entries_on_import_id" - t.index ["import_locked"], name: "index_entries_on_import_locked_true", where: "(import_locked = true)" - t.index ["parent_entry_id"], name: "index_entries_on_parent_entry_id" - t.index ["user_modified"], name: "index_entries_on_user_modified_true", where: "(user_modified = true)" - end - - create_table "eval_datasets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "description" - t.string "eval_type", null: false - t.string "version", default: "1.0", null: false - t.integer "sample_count", default: 0 - t.jsonb "metadata", default: {} - t.boolean "active", default: true - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["eval_type", "active"], name: "index_eval_datasets_on_eval_type_and_active" - t.index ["name"], name: "index_eval_datasets_on_name", unique: true + t.index ["name"], name: "index_entries_on_name" end - create_table "eval_results", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_run_id", null: false - t.uuid "eval_sample_id", null: false - t.jsonb "actual_output", null: false - t.boolean "correct", null: false - t.boolean "exact_match", default: false - t.boolean "hierarchical_match", default: false - t.boolean "null_expected", default: false - t.boolean "null_returned", default: false - t.float "fuzzy_score" - t.integer "latency_ms" - t.integer "prompt_tokens" - t.integer "completion_tokens" - t.decimal "cost", precision: 10, scale: 6 - t.jsonb "metadata", default: {} + create_table "exchange_rates", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "alternative_match", default: false - t.index ["eval_run_id", "correct"], name: "index_eval_results_on_eval_run_id_and_correct" - t.index ["eval_run_id"], name: "index_eval_results_on_eval_run_id" - t.index ["eval_sample_id"], name: "index_eval_results_on_eval_sample_id" - end - - create_table "eval_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_dataset_id", null: false - t.string "name" - t.string "status", default: "pending", null: false - t.string "provider", null: false - t.string "model", null: false - t.jsonb "provider_config", default: {} - t.jsonb "metrics", default: {} - t.integer "total_prompt_tokens", default: 0 - t.integer "total_completion_tokens", default: 0 - t.decimal "total_cost", precision: 10, scale: 6, default: "0.0" - t.datetime "started_at" - t.datetime "completed_at" - t.text "error_message" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["eval_dataset_id", "model"], name: "index_eval_runs_on_eval_dataset_id_and_model" - t.index ["eval_dataset_id"], name: "index_eval_runs_on_eval_dataset_id" - t.index ["provider", "model"], name: "index_eval_runs_on_provider_and_model" - t.index ["status"], name: "index_eval_runs_on_status" - end - - create_table "eval_samples", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "eval_dataset_id", null: false - t.jsonb "input_data", null: false - t.jsonb "expected_output", null: false - t.jsonb "context_data", default: {} - t.string "difficulty", default: "medium" - t.string "tags", default: [], array: true - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["eval_dataset_id", "difficulty"], name: "index_eval_samples_on_eval_dataset_id_and_difficulty" - t.index ["eval_dataset_id"], name: "index_eval_samples_on_eval_dataset_id" - t.index ["tags"], name: "index_eval_samples_on_tags", using: :gin - end - - create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "from_currency", null: false - t.string "to_currency", null: false - t.date "first_provider_rate_on" - t.string "provider_name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true - end - - create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.date "date", null: false t.string "from_currency", null: false - t.string "to_currency", null: false t.decimal "rate", null: false - t.date "date", null: false - t.datetime "created_at", null: false + t.string "to_currency", null: false t.datetime "updated_at", null: false + t.index ["date", "from_currency", "to_currency"], name: "index_exchange_rates_on_date_and_currencies" t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true t.index ["from_currency"], name: "index_exchange_rates_on_from_currency" t.index ["to_currency"], name: "index_exchange_rates_on_to_currency" end - create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" + create_table "families", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "auto_sync_on_login", default: true, null: false + t.string "country", default: "US" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "currency", default: "USD" - t.string "locale", default: "en" - t.string "stripe_customer_id" - t.string "date_format", default: "%m-%d-%Y" - t.string "country", default: "US" - t.string "timezone" t.boolean "data_enrichment_enabled", default: false + t.string "date_format", default: "%m-%d-%Y" t.boolean "early_access", default: false - t.boolean "auto_sync_on_login", default: true, null: false t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" } t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" } - t.boolean "recurring_transactions_disabled", default: false, null: false - t.integer "month_start_day", default: 1, null: false - t.string "vector_store_id" - t.string "moniker", default: "Family", null: false - t.string "assistant_type", default: "builtin", null: false - t.string "default_account_sharing", default: "shared", null: false - t.string "enabled_currencies", array: true - t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" - t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" - end - - create_table "family_documents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "filename", null: false - t.string "content_type" - t.integer "file_size" - t.string "provider_file_id" - t.string "status", default: "pending", null: false - t.jsonb "metadata", default: {} - t.datetime "created_at", null: false + t.string "locale", default: "en" + t.string "name" + t.string "stripe_customer_id" + t.string "timezone" t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_family_documents_on_family_id" - t.index ["provider_file_id"], name: "index_family_documents_on_provider_file_id" - t.index ["status"], name: "index_family_documents_on_status" end - create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "status", default: "pending", null: false + create_table "family_exports", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "family_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["family_id"], name: "index_family_exports_on_family_id" end - create_table "family_merchant_associations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.uuid "merchant_id", null: false - t.datetime "unlinked_at" + create_table "holdings", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_id", null: false + t.decimal "amount", precision: 19, scale: 4, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id", "merchant_id"], name: "idx_on_family_id_merchant_id_23e883e08f", unique: true - t.index ["family_id"], name: "index_family_merchant_associations_on_family_id" - t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id" - end - - create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "account_id", null: false - t.uuid "security_id", null: false + t.string "currency", null: false t.date "date", null: false - t.decimal "qty", precision: 24, scale: 8, null: false t.decimal "price", precision: 19, scale: 4, null: false - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.datetime "created_at", null: false + t.decimal "qty", precision: 19, scale: 4, null: false + t.string "security_id", null: false t.datetime "updated_at", null: false t.string "external_id" t.decimal "cost_basis", precision: 19, scale: 4 @@ -658,181 +307,113 @@ t.index ["security_id"], name: "index_holdings_on_security_id" end - create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonation_session_id", null: false - t.string "controller" + create_table "impersonation_session_logs", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "action" - t.text "path" - t.string "method" - t.string "ip_address" - t.text "user_agent" + t.string "controller" t.datetime "created_at", null: false + t.string "impersonation_session_id", null: false + t.string "ip_address" + t.string "method" + t.text "path" t.datetime "updated_at", null: false + t.text "user_agent" t.index ["impersonation_session_id"], name: "index_impersonation_session_logs_on_impersonation_session_id" end - create_table "impersonation_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "impersonator_id", null: false - t.uuid "impersonated_id", null: false - t.string "status", default: "pending", null: false + create_table "impersonation_sessions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "impersonated_id", null: false + t.string "impersonator_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["impersonated_id"], name: "index_impersonation_sessions_on_impersonated_id" t.index ["impersonator_id"], name: "index_impersonation_sessions_on_impersonator_id" end - create_table "import_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "type", null: false - t.string "key" - t.string "value" + create_table "import_mappings", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.boolean "create_when_empty", default: true - t.uuid "import_id", null: false - t.string "mappable_type" - t.uuid "mappable_id" t.datetime "created_at", null: false + t.string "import_id", null: false + t.string "key" + t.string "mappable_id" + t.string "mappable_type" + t.string "type", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["import_id"], name: "index_import_mappings_on_import_id" t.index ["mappable_type", "mappable_id"], name: "index_import_mappings_on_mappable" end - create_table "import_rows", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "import_id", null: false + create_table "import_rows", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "account" - t.string "date" - t.string "qty" - t.string "ticker" - t.string "price" t.string "amount" - t.string "currency" - t.string "name" t.string "category" - t.string "tags" + t.datetime "created_at", null: false + t.string "currency" + t.string "date" t.string "entity_type" + t.string "exchange_operating_mic" + t.string "import_id", null: false + t.string "name" t.text "notes" - t.datetime "created_at", null: false + t.string "price" + t.string "qty" + t.string "tags" + t.string "ticker" t.datetime "updated_at", null: false - t.string "exchange_operating_mic" - t.string "category_parent" - t.string "category_color" - t.string "category_classification" - t.string "category_icon" - t.string "resource_type" - t.boolean "active" - t.string "effective_date" - t.text "conditions" - t.text "actions" - t.integer "source_row_number", null: false - t.index ["import_id", "source_row_number"], name: "index_import_rows_on_import_id_and_source_row_number", unique: true t.index ["import_id"], name: "index_import_rows_on_import_id" t.check_constraint "source_row_number > 0", name: "chk_import_rows_source_row_number_positive" end - create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.jsonb "column_mappings" - t.string "status" - t.string "raw_file_str" - t.string "normalized_csv_str" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "col_sep", default: "," - t.uuid "family_id", null: false - t.uuid "account_id" - t.string "type", null: false - t.string "date_col_label" + create_table "imports", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "account_col_label" + t.string "account_id" t.string "amount_col_label" - t.string "name_col_label" + t.string "amount_type_inflow_value" + t.string "amount_type_strategy", default: "signed_amount" t.string "category_col_label" - t.string "tags_col_label" - t.string "account_col_label" - t.string "qty_col_label" - t.string "ticker_col_label" - t.string "price_col_label" - t.string "entity_type_col_label" - t.string "notes_col_label" + t.string "col_sep", default: "," + t.json "column_mappings" + t.datetime "created_at", null: false t.string "currency_col_label" + t.string "date_col_label" t.string "date_format", default: "%m/%d/%Y" - t.string "signage_convention", default: "inflows_positive" + t.string "entity_type_col_label" t.string "error" - t.string "number_format" t.string "exchange_operating_mic_col_label" - t.string "amount_type_strategy", default: "signed_amount" - t.string "amount_type_inflow_value" - t.integer "rows_to_skip", default: 0, null: false - t.integer "rows_count", default: 0, null: false - t.string "amount_type_identifier_value" - t.text "ai_summary" - t.string "document_type" - t.jsonb "extracted_data" + t.string "family_id", null: false + t.string "name_col_label" + t.string "normalized_csv_str" + t.string "notes_col_label" + t.string "number_format" + t.string "price_col_label" + t.string "qty_col_label" + t.string "raw_file_str" + t.string "signage_convention", default: "inflows_positive" + t.string "status" + t.string "tags_col_label" + t.string "ticker_col_label" + t.string "type", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_imports_on_family_id" end - create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "indexa_capital_item_id", null: false - t.string "name" - t.string "indexa_capital_account_id" - t.string "account_number" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.string "indexa_capital_authorization_id" - t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" - t.boolean "activities_fetch_pending", default: false - t.date "sync_start_date" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["indexa_capital_authorization_id"], name: "idx_on_indexa_capital_authorization_id_58db208d52" - t.index ["indexa_capital_item_id", "indexa_capital_account_id"], name: "index_indexa_capital_accounts_on_item_and_account_id", unique: true, where: "(indexa_capital_account_id IS NOT NULL)" - t.index ["indexa_capital_item_id"], name: "index_indexa_capital_accounts_on_indexa_capital_item_id" - end - - create_table "indexa_capital_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "username" - t.string "document" - t.text "password" + create_table "investments", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.json "locked_attributes", default: {} + t.string "subtype" t.datetime "updated_at", null: false - t.text "api_token" - t.index ["family_id"], name: "index_indexa_capital_items_on_family_id" - t.index ["status"], name: "index_indexa_capital_items_on_status" end - create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "invitations", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "accepted_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} - t.string "subtype" - end - - create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "email" + t.datetime "expires_at" + t.string "family_id", null: false + t.string "inviter_id", null: false t.string "role" t.string "token" - t.uuid "family_id", null: false - t.uuid "inviter_id", null: false - t.datetime "accepted_at" - t.datetime "expires_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "token_digest" t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)" @@ -843,9 +424,9 @@ t.index ["token_digest"], name: "index_invitations_on_token_digest", unique: true, where: "(token_digest IS NOT NULL)" end - create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "token", null: false + create_table "invite_codes", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "token", null: false t.datetime "updated_at", null: false t.string "token_digest" t.index ["token"], name: "index_invite_codes_on_token", unique: true @@ -869,171 +450,88 @@ t.index ["family_id"], name: "index_llm_usages_on_family_id" end - create_table "loans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "loans", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "rate_type" - t.decimal "interest_rate", precision: 10, scale: 3 - t.integer "term_months" t.decimal "initial_balance", precision: 19, scale: 4 - t.jsonb "locked_attributes", default: {} + t.decimal "interest_rate", precision: 10, scale: 3 + t.json "locked_attributes", default: {} + t.string "rate_type" t.string "subtype" - end - - create_table "lunchflow_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "lunchflow_item_id", null: false - t.string "name" - t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "provider" - t.string "account_type" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "holdings_supported", default: true, null: false - t.jsonb "raw_holdings_payload" - t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" - t.index ["lunchflow_item_id", "account_id"], name: "index_lunchflow_accounts_on_item_and_account_id", unique: true, where: "(account_id IS NOT NULL)" - t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" - end - - create_table "lunchflow_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false + t.integer "term_months" t.datetime "updated_at", null: false - t.text "api_key" - t.string "base_url" - t.index ["family_id"], name: "index_lunchflow_items_on_family_id" - t.index ["status"], name: "index_lunchflow_items_on_status" end - create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false + create_table "merchants", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "color" - t.uuid "family_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "family_id" t.string "logo_url" - t.string "website_url" - t.string "type", null: false - t.string "source" + t.string "name", null: false t.string "provider_merchant_id" - t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name", unique: true, where: "((type)::text = 'FamilyMerchant'::text)" + t.string "source" + t.string "type", null: false + t.datetime "updated_at", null: false + t.string "website_url" + t.index ["family_id", "name"], name: "index_merchants_on_family_id_and_name" t.index ["family_id"], name: "index_merchants_on_family_id" - t.index ["provider_merchant_id", "source"], name: "index_merchants_on_provider_merchant_id_and_source", unique: true, where: "((provider_merchant_id IS NOT NULL) AND ((type)::text = 'ProviderMerchant'::text))" - t.index ["source", "name"], name: "index_merchants_on_source_and_name", unique: true, where: "((type)::text = 'ProviderMerchant'::text)" + t.index ["source", "name"], name: "index_merchants_on_source_and_name" t.index ["type"], name: "index_merchants_on_type" end - create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "mercury_item_id", null: false - t.string "name" - t.string "account_id", null: false - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["mercury_item_id", "account_id"], name: "index_mercury_accounts_on_item_and_account_id", unique: true - t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id" - end - - create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.text "token" - t.string "base_url" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_mercury_items_on_family_id" - t.index ["status"], name: "index_mercury_items_on_status" - end - - create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "chat_id", null: false - t.string "type", null: false - t.string "status", default: "complete", null: false - t.text "content" + create_table "messages", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ai_model" + t.string "chat_id", null: false + t.text "content" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.boolean "debug", default: false t.string "provider_id" t.boolean "reasoning", default: false + t.string "status", default: "complete", null: false + t.string "type", null: false + t.datetime "updated_at", null: false t.index ["chat_id"], name: "index_messages_on_chat_id" end - create_table "mobile_devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false + create_table "mobile_devices", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "app_version" + t.datetime "created_at", null: false t.string "device_id" t.string "device_name" t.string "device_type" - t.string "os_version" - t.string "app_version" t.datetime "last_seen_at" - t.datetime "created_at", null: false + t.integer "oauth_application_id" + t.string "os_version" t.datetime "updated_at", null: false + t.string "user_id", null: false + t.index ["oauth_application_id"], name: "index_mobile_devices_on_oauth_application_id" t.index ["user_id", "device_id"], name: "index_mobile_devices_on_user_id_and_device_id", unique: true t.index ["user_id"], name: "index_mobile_devices_on_user_id" end create_table "oauth_access_grants", force: :cascade do |t| - t.string "resource_owner_id", null: false t.bigint "application_id", null: false - t.string "token", null: false + t.datetime "created_at", null: false t.integer "expires_in", null: false t.text "redirect_uri", null: false - t.string "scopes", default: "", null: false - t.datetime "created_at", null: false + t.string "resource_owner_id", null: false t.datetime "revoked_at" + t.string "scopes", default: "", null: false + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true end create_table "oauth_access_tokens", force: :cascade do |t| - t.string "resource_owner_id" t.bigint "application_id", null: false - t.string "token", null: false - t.string "refresh_token" - t.integer "expires_in" - t.string "scopes" t.datetime "created_at", null: false - t.datetime "revoked_at" + t.integer "expires_in" t.string "previous_refresh_token", default: "", null: false - t.uuid "mobile_device_id" + t.string "refresh_token" + t.string "resource_owner_id" + t.datetime "revoked_at" + t.string "scopes" + t.string "token", null: false t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" t.index ["mobile_device_id"], name: "index_oauth_access_tokens_on_mobile_device_id" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true @@ -1042,290 +540,216 @@ end create_table "oauth_applications", force: :cascade do |t| + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false t.string "name", null: false - t.string "uid", null: false - t.string "secret", null: false + t.string "owner_id" + t.string "owner_type" t.text "redirect_uri", null: false t.string "scopes", default: "", null: false - t.boolean "confidential", default: true, null: false - t.datetime "created_at", null: false + t.string "secret", null: false + t.string "uid", null: false t.datetime "updated_at", null: false - t.uuid "owner_id" - t.string "owner_type" t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end - create_table "oidc_identities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "provider", null: false - t.string "uid", null: false - t.jsonb "info", default: {} - t.datetime "last_authenticated_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "issuer" - t.index ["issuer"], name: "index_oidc_identities_on_issuer" - t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true - t.index ["user_id"], name: "index_oidc_identities_on_user_id" - end - - create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "other_assets", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end - create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "other_liabilities", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} + t.json "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false end - create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "plaid_item_id", null: false - t.string "plaid_id", null: false - t.string "plaid_type", null: false - t.string "plaid_subtype" - t.decimal "current_balance", precision: 19, scale: 4 + create_table "plaid_accounts", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.decimal "available_balance", precision: 19, scale: 4 + t.datetime "created_at", null: false t.string "currency", null: false - t.string "name", null: false + t.decimal "current_balance", precision: 19, scale: 4 t.string "mask" - t.datetime "created_at", null: false + t.string "name", null: false + t.string "plaid_id", null: false + t.string "plaid_item_id", null: false + t.string "plaid_subtype" + t.string "plaid_type", null: false + t.json "raw_investments_payload", default: {} + t.json "raw_liabilities_payload", default: {} + t.json "raw_payload", default: {} + t.json "raw_transactions_payload", default: {} t.datetime "updated_at", null: false - t.jsonb "raw_payload", default: {} - t.jsonb "raw_transactions_payload", default: {} - t.jsonb "raw_holdings_payload", default: {} - t.jsonb "raw_liabilities_payload", default: {} - t.index ["plaid_item_id", "plaid_id"], name: "index_plaid_accounts_on_item_and_plaid_id", unique: true + t.index ["plaid_id"], name: "index_plaid_accounts_on_plaid_id", unique: true t.index ["plaid_item_id"], name: "index_plaid_accounts_on_plaid_item_id" end - create_table "plaid_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + create_table "plaid_items", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "access_token" - t.string "plaid_id", null: false + t.json "available_products", default: [] + t.json "billed_products", default: [] + t.datetime "created_at", null: false + t.string "family_id", null: false + t.string "institution_color" + t.string "institution_id" + t.string "institution_url" t.string "name" t.string "next_cursor" - t.boolean "scheduled_for_deletion", default: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "available_products", default: [], array: true - t.string "billed_products", default: [], array: true + t.string "plaid_id", null: false t.string "plaid_region", default: "us", null: false - t.string "institution_url" - t.string "institution_id" - t.string "institution_color" + t.json "raw_institution_payload", default: {} + t.json "raw_payload", default: {} + t.boolean "scheduled_for_deletion", default: false t.string "status", default: "good", null: false - t.jsonb "raw_payload", default: {} - t.jsonb "raw_institution_payload", default: {} + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_plaid_items_on_family_id" t.index ["plaid_id"], name: "index_plaid_items_on_plaid_id", unique: true end - create_table "properties", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "year_built" - t.integer "area_value" + create_table "properties", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "area_unit" - t.jsonb "locked_attributes", default: {} - t.string "subtype" - end - - create_table "recurring_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.uuid "merchant_id" - t.decimal "amount", precision: 19, scale: 4, null: false - t.string "currency", null: false - t.integer "expected_day_of_month", null: false - t.date "last_occurrence_date", null: false - t.date "next_expected_date", null: false - t.string "status", default: "active", null: false - t.integer "occurrence_count", default: 0, null: false + t.integer "area_value" t.datetime "created_at", null: false + t.json "locked_attributes", default: {} + t.string "subtype" t.datetime "updated_at", null: false - t.string "name" - t.boolean "manual", default: false, null: false - t.decimal "expected_amount_min", precision: 19, scale: 4 - t.decimal "expected_amount_max", precision: 19, scale: 4 - t.decimal "expected_amount_avg", precision: 19, scale: 4 - t.uuid "account_id" - t.index ["account_id"], name: "index_recurring_transactions_on_account_id" - t.index ["family_id", "account_id", "merchant_id", "amount", "currency"], name: "idx_recurring_txns_acct_merchant", unique: true, where: "(merchant_id IS NOT NULL)" - t.index ["family_id", "account_id", "name", "amount", "currency"], name: "idx_recurring_txns_acct_name", unique: true, where: "((name IS NOT NULL) AND (merchant_id IS NULL))" - t.index ["family_id", "status"], name: "index_recurring_transactions_on_family_id_and_status" - t.index ["family_id"], name: "index_recurring_transactions_on_family_id" - t.index ["merchant_id"], name: "index_recurring_transactions_on_merchant_id" - t.index ["next_expected_date"], name: "index_recurring_transactions_on_next_expected_date" + t.integer "year_built" end - create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "inflow_transaction_id", null: false - t.uuid "outflow_transaction_id", null: false + create_table "rejected_transfers", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "inflow_transaction_id", null: false + t.string "outflow_transaction_id", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26", unique: true t.index ["inflow_transaction_id"], name: "index_rejected_transfers_on_inflow_transaction_id" t.index ["outflow_transaction_id"], name: "index_rejected_transfers_on_outflow_transaction_id" end - create_table "rule_actions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false + create_table "rule_actions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "action_type", null: false - t.string "value" t.datetime "created_at", null: false + t.string "rule_id", null: false t.datetime "updated_at", null: false + t.string "value" t.index ["rule_id"], name: "index_rule_actions_on_rule_id" end - create_table "rule_conditions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id" - t.uuid "parent_id" + create_table "rule_conditions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "condition_type", null: false - t.string "operator", null: false - t.string "value" t.datetime "created_at", null: false + t.string "operator", null: false + t.string "parent_id" + t.string "rule_id" t.datetime "updated_at", null: false + t.string "value" t.index ["parent_id"], name: "index_rule_conditions_on_parent_id" t.index ["rule_id"], name: "index_rule_conditions_on_rule_id" end - create_table "rule_runs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "rule_id", null: false - t.string "rule_name" - t.string "execution_type", null: false - t.string "status", null: false - t.integer "transactions_queued", default: 0, null: false - t.integer "transactions_processed", default: 0, null: false - t.integer "transactions_modified", default: 0, null: false - t.integer "pending_jobs_count", default: 0, null: false - t.datetime "executed_at", null: false - t.text "error_message" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["executed_at"], name: "index_rule_runs_on_executed_at" - t.index ["rule_id", "executed_at"], name: "index_rule_runs_on_rule_id_and_executed_at" - t.index ["rule_id"], name: "index_rule_runs_on_rule_id" - end - - create_table "rules", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "resource_type", null: false - t.date "effective_date" + create_table "rules", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.boolean "active", default: false, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.date "effective_date" + t.string "family_id", null: false t.string "name" + t.string "resource_type", null: false + t.datetime "updated_at", null: false t.index ["family_id"], name: "index_rules_on_family_id" end - create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "ticker", null: false - t.string "name" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + create_table "securities", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "country_code" - t.string "exchange_mic" + t.datetime "created_at", null: false t.string "exchange_acronym" - t.string "logo_url" + t.string "exchange_mic" t.string "exchange_operating_mic" - t.boolean "offline", default: false, null: false t.datetime "failed_fetch_at" t.integer "failed_fetch_count", default: 0, null: false t.datetime "last_health_check_at" - t.string "website_url" - t.string "kind", default: "standard", null: false - t.string "price_provider" - t.string "offline_reason" - t.date "first_provider_price_on" - t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true + t.string "logo_url" + t.string "name" + t.boolean "offline", default: false, null: false + t.string "ticker", null: false + t.datetime "updated_at", null: false t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" - t.index ["kind"], name: "index_securities_on_kind" - t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason" - t.index ["price_provider"], name: "index_securities_on_price_provider" - t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind" + t.index ["ticker", "exchange_operating_mic"], name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true end - create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "security_prices", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "currency", default: "USD", null: false t.date "date", null: false t.decimal "price", precision: 19, scale: 4, null: false - t.string "currency", default: "USD", null: false - t.datetime "created_at", null: false + t.string "security_id" t.datetime "updated_at", null: false - t.uuid "security_id" - t.boolean "provisional", default: false, null: false t.index ["security_id", "date", "currency"], name: "index_security_prices_on_security_id_and_date_and_currency", unique: true t.index ["security_id"], name: "index_security_prices_on_security_id" end - create_table "sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.string "user_agent" - t.string "ip_address" + create_table "sessions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "active_impersonator_session_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "active_impersonator_session_id" + t.json "data", default: {} + t.string "ip_address" + t.json "prev_transaction_page_params", default: {} t.datetime "subscribed_at" - t.jsonb "prev_transaction_page_params", default: {} - t.jsonb "data", default: {} - t.string "ip_address_digest" + t.datetime "updated_at", null: false + t.string "user_agent" + t.string "user_id", null: false t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id" t.index ["ip_address_digest"], name: "index_sessions_on_ip_address_digest" t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "settings", force: :cascade do |t| - t.string "var", null: false - t.text "value" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "value" + t.string "var", null: false t.index ["var"], name: "index_settings_on_var", unique: true end - create_table "simplefin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "simplefin_item_id", null: false - t.string "name" + create_table "simplefin_accounts", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "account_id" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.string "account_type" t.string "account_subtype" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" + t.string "account_type" + t.decimal "available_balance", precision: 19, scale: 4 + t.datetime "balance_date" t.datetime "created_at", null: false + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.json "extra" + t.string "name" + t.json "org_data" + t.json "raw_payload" + t.json "raw_transactions_payload" + t.string "simplefin_item_id", null: false t.datetime "updated_at", null: false - t.datetime "balance_date" - t.jsonb "extra" - t.jsonb "org_data" - t.jsonb "raw_holdings_payload" t.index ["account_id"], name: "index_simplefin_accounts_on_account_id" t.index ["simplefin_item_id", "account_id"], name: "idx_unique_sfa_per_item_and_upstream", unique: true, where: "(account_id IS NOT NULL)" t.index ["simplefin_item_id"], name: "index_simplefin_accounts_on_simplefin_item_id" end - create_table "simplefin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + create_table "simplefin_items", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.text "access_url" - t.string "name" + t.datetime "created_at", null: false + t.string "family_id", null: false t.string "institution_id" t.string "institution_name" t.string "institution_url" - t.string "status", default: "good" + t.string "name" + t.boolean "pending_account_setup", default: false, null: false + t.json "raw_institution_payload" + t.json "raw_payload" t.boolean "scheduled_for_deletion", default: false - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.datetime "created_at", null: false + t.string "status", default: "good" t.datetime "updated_at", null: false - t.boolean "pending_account_setup", default: false, null: false - t.string "institution_domain" - t.string "institution_color" - t.date "sync_start_date" t.index ["family_id"], name: "index_simplefin_items_on_family_id" t.index ["institution_domain"], name: "index_simplefin_items_on_institution_domain" t.index ["institution_id"], name: "index_simplefin_items_on_institution_id" @@ -1333,229 +757,92 @@ t.index ["status"], name: "index_simplefin_items_on_status" end - create_table "snaptrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "snaptrade_item_id", null: false - t.string "name" - t.string "snaptrade_account_id" - t.string "snaptrade_authorization_id" - t.string "account_number" - t.string "brokerage_name" - t.string "currency" - t.decimal "current_balance", precision: 19, scale: 4 - t.decimal "cash_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "provider" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: [] - t.datetime "last_holdings_sync" - t.datetime "last_activities_sync" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "activities_fetch_pending", default: false - t.date "sync_start_date" - t.index ["snaptrade_item_id", "snaptrade_account_id"], name: "index_snaptrade_accounts_on_item_and_snaptrade_account_id", unique: true, where: "(snaptrade_account_id IS NOT NULL)" - t.index ["snaptrade_item_id"], name: "index_snaptrade_accounts_on_snaptrade_item_id" - end - - create_table "snaptrade_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.datetime "last_synced_at" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "client_id" - t.string "consumer_key" - t.string "snaptrade_user_id" - t.string "snaptrade_user_secret" + create_table "subscriptions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.decimal "amount", precision: 19, scale: 4 t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_snaptrade_items_on_family_id" - t.index ["status"], name: "index_snaptrade_items_on_status" - end - - create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "sophtron_item_id", null: false - t.string "name", null: false - t.string "account_id", null: false t.string "currency" - t.decimal "balance", precision: 19, scale: 4 - t.decimal "available_balance", precision: 19, scale: 4 - t.string "account_status" - t.string "account_type" - t.string "account_sub_type" - t.datetime "last_updated" - t.jsonb "institution_metadata" - t.jsonb "raw_payload" - t.jsonb "raw_transactions_payload" - t.string "customer_id", null: false - t.string "member_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_sophtron_accounts_on_account_id" - t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id" - t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true - end - - create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false - t.string "name" - t.string "institution_id" - t.string "institution_name" - t.string "institution_domain" - t.string "institution_url" - t.string "institution_color" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false - t.boolean "pending_account_setup", default: false - t.datetime "sync_start_date" - t.jsonb "raw_payload" - t.jsonb "raw_institution_payload" - t.string "user_id", null: false - t.string "access_key", null: false - t.string "base_url" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["family_id"], name: "index_sophtron_items_on_family_id" - t.index ["status"], name: "index_sophtron_items_on_status" - end - - create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id" - t.string "event_type", null: false - t.string "provider" - t.string "ip_address" - t.string "user_agent" - t.jsonb "metadata", default: {}, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["created_at"], name: "index_sso_audit_logs_on_created_at" - t.index ["event_type"], name: "index_sso_audit_logs_on_event_type" - t.index ["user_id", "created_at"], name: "index_sso_audit_logs_on_user_id_and_created_at" - t.index ["user_id"], name: "index_sso_audit_logs_on_user_id" - end - - create_table "sso_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "strategy", null: false - t.string "name", null: false - t.string "label", null: false - t.string "icon" - t.boolean "enabled", default: true, null: false - t.string "issuer" - t.string "client_id" - t.string "client_secret" - t.string "redirect_uri" - t.jsonb "settings", default: {}, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["enabled"], name: "index_sso_providers_on_enabled" - t.index ["name"], name: "index_sso_providers_on_name", unique: true - end - - create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + t.datetime "current_period_ends_at" + t.string "family_id", null: false + t.string "interval" t.string "status", null: false t.string "stripe_id" - t.decimal "amount", precision: 19, scale: 4 - t.string "currency" - t.string "interval" - t.datetime "current_period_ends_at" t.datetime "trial_ends_at" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.boolean "cancel_at_period_end", default: false, null: false t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end - create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "syncable_type", null: false - t.uuid "syncable_id", null: false - t.string "status", default: "pending" - t.string "error" - t.jsonb "data" + create_table "syncs", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "completed_at" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "parent_id" + t.json "data" + t.string "error" + t.datetime "failed_at" + t.string "parent_id" t.datetime "pending_at" + t.string "status", default: "pending" + t.string "syncable_id", null: false + t.string "syncable_type", null: false t.datetime "syncing_at" - t.datetime "completed_at" - t.datetime "failed_at" - t.date "window_start_date" + t.datetime "updated_at", null: false t.date "window_end_date" - t.text "sync_stats" + t.date "window_start_date" t.index ["parent_id"], name: "index_syncs_on_parent_id" t.index ["status"], name: "index_syncs_on_status" t.index ["syncable_type", "syncable_id"], name: "index_syncs_on_syncable" end - create_table "taggings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "tag_id", null: false - t.string "taggable_type" - t.uuid "taggable_id" + create_table "taggings", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "tag_id", null: false + t.string "taggable_id" + t.string "taggable_type" t.datetime "updated_at", null: false t.index ["tag_id"], name: "index_taggings_on_tag_id" t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable" end - create_table "tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name" + create_table "tags", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "color", default: "#e99537", null: false - t.uuid "family_id", null: false t.datetime "created_at", null: false + t.string "family_id", null: false + t.string "name" t.datetime "updated_at", null: false t.index ["family_id"], name: "index_tags_on_family_id" end - create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "message_id", null: false - t.string "provider_id", null: false + create_table "tool_calls", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.json "function_arguments" + t.string "function_name" + t.json "function_result" + t.string "message_id", null: false t.string "provider_call_id" + t.string "provider_id", null: false t.string "type", null: false - t.string "function_name" - t.jsonb "function_arguments" - t.jsonb "function_result" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["message_id"], name: "index_tool_calls_on_message_id" end - create_table "trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "security_id", null: false - t.decimal "qty", precision: 24, scale: 8 - t.decimal "price", precision: 19, scale: 10 + create_table "trades", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "currency" - t.jsonb "locked_attributes", default: {} - t.string "investment_activity_label" - t.decimal "fee", precision: 19, scale: 4, default: "0.0", null: false - t.index ["investment_activity_label"], name: "index_trades_on_investment_activity_label" + t.json "locked_attributes", default: {} + t.decimal "price", precision: 19, scale: 4 + t.decimal "qty", precision: 19, scale: 4 + t.string "security_id", null: false + t.datetime "updated_at", null: false t.index ["security_id"], name: "index_trades_on_security_id" end - create_table "transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "transactions", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "category_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.uuid "category_id" - t.uuid "merchant_id" - t.jsonb "locked_attributes", default: {} - t.string "kind", default: "standard", null: false t.string "external_id" - t.jsonb "extra", default: {}, null: false - t.string "investment_activity_label" + t.string "kind", default: "standard", null: false + t.json "locked_attributes", default: {} + t.string "merchant_id" + t.datetime "updated_at", null: false t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["external_id"], name: "index_transactions_on_external_id" t.index ["extra"], name: "index_transactions_on_extra", using: :gin @@ -1564,12 +851,12 @@ t.index ["merchant_id"], name: "index_transactions_on_merchant_id" end - create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "inflow_transaction_id", null: false - t.uuid "outflow_transaction_id", null: false - t.string "status", default: "pending", null: false - t.text "notes" + create_table "transfers", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false + t.string "inflow_transaction_id", null: false + t.text "notes" + t.string "outflow_transaction_id", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id" @@ -1577,65 +864,56 @@ t.index ["status"], name: "index_transfers_on_status" end - create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "family_id", null: false + create_table "users", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "active", default: true, null: false + t.boolean "ai_enabled", default: false, null: false + t.datetime "created_at", null: false + t.string "default_account_order", default: "name_asc" + t.string "default_period", default: "last_30_days", null: false + t.string "email" + t.string "family_id", null: false t.string "first_name" + t.json "goals", default: [] t.string "last_name" - t.string "email" - t.string "password_digest" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "role", default: "member", null: false - t.boolean "active", default: true, null: false + t.string "last_viewed_chat_id" t.datetime "onboarded_at" - t.string "unconfirmed_email" - t.string "otp_secret" + t.json "otp_backup_codes", default: [] t.boolean "otp_required", default: false, null: false - t.string "otp_backup_codes", default: [], array: true - t.boolean "show_sidebar", default: true - t.string "default_period", default: "last_30_days", null: false - t.uuid "last_viewed_chat_id" - t.boolean "show_ai_sidebar", default: true - t.boolean "ai_enabled", default: false, null: false - t.string "theme", default: "system" - t.boolean "rule_prompts_disabled", default: false + t.string "otp_secret" + t.string "password_digest" + t.string "role", default: "member", null: false t.datetime "rule_prompt_dismissed_at" - t.text "goals", default: [], array: true - t.datetime "set_onboarding_preferences_at" + t.boolean "rule_prompts_disabled", default: false t.datetime "set_onboarding_goals_at" - t.string "default_account_order", default: "name_asc" - t.jsonb "preferences", default: {}, null: false - t.string "locale" - t.string "ui_layout" - t.uuid "default_account_id" - t.string "webauthn_id" - t.index ["default_account_id"], name: "index_users_on_default_account_id" + t.datetime "set_onboarding_preferences_at" + t.boolean "show_ai_sidebar", default: true + t.boolean "show_sidebar", default: true + t.string "theme", default: "system" + t.string "unconfirmed_email" + t.datetime "updated_at", null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" - t.index ["locale"], name: "index_users_on_locale" - t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)" - t.index ["preferences"], name: "index_users_on_preferences", using: :gin - t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true, where: "(webauthn_id IS NOT NULL)" + t.index ["otp_secret"], name: "index_users_on_otp_secret" end - create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "valuations", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "locked_attributes", default: {} t.string "kind", default: "reconciliation", null: false + t.json "locked_attributes", default: {} + t.datetime "updated_at", null: false end - create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + create_table "vehicles", id: :string, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "year" - t.integer "mileage_value" - t.string "mileage_unit" + t.json "locked_attributes", default: {} t.string "make" + t.string "mileage_unit" + t.integer "mileage_value" t.string "model" - t.jsonb "locked_attributes", default: {} t.string "subtype" + t.datetime "updated_at", null: false + t.integer "year" end create_table "webauthn_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/db/seeds.rb b/db/seeds.rb index 3de4ad5e2a3..50a6c482cc3 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -4,7 +4,9 @@ puts 'Run the following command to create demo data: `rake demo_data:default`' if Rails.env.development? -Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |file| - puts "Loading seed file: #{File.basename(file)}" - require file +ActiveRecord::Base.transaction do + Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |file| + puts "Loading seed file: #{File.basename(file)}" + require file + end end diff --git a/docs/api/chats.md b/docs/api/chats.md index a729468e6de..66399bbbc5e 100644 --- a/docs/api/chats.md +++ b/docs/api/chats.md @@ -56,4 +56,12 @@ Errors conform to the shared `ErrorResponse` schema in the OpenAPI document: } ``` -Common error codes include `unauthorized`, `forbidden`, `feature_disabled`, `not_found`, `unprocessable_entity`, and `rate_limit_exceeded`. \ No newline at end of file +Common error codes: +- `unauthorized` - Invalid or missing authentication +- `forbidden` - Insufficient permissions or AI not enabled +- `not_found` - Resource not found +- `unprocessable_entity` - Invalid request data + +## Rate Limits + +Chat API endpoints are subject to the standard API rate limits based on your API key tier. \ No newline at end of file diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index afd462752e6..5ace300ab3d 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -95,6 +95,12 @@ SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep" POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword" ``` +We also recommend leaving YJIT disabled by default on smaller hosts. If you want to trade more memory for potentially better Ruby throughput, opt in explicitly: + +```txt +ENABLE_YJIT="true" +``` + #### Using HTTPS Assuming you want to access your instance from the internet, you should have secured your URL address with an SSL certificate. @@ -158,7 +164,6 @@ BINDING=:: bin/dev # IPv6 dual-stack ``` The bundled devcontainer at `.devcontainer/docker-compose.yml` already pins `BINDING: "0.0.0.0"` so Docker port forwarding reaches the app — no manual override needed when using the devcontainer. - ### Step 4: Run the app You are now ready to run the app. Start with the following command to make sure everything is working: diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000000..c6aa6a31ed3 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +# Keep these pins aligned with .ruby-version +ruby = "3.4.6" diff --git a/test/controllers/api/v1/accounts_controller_test.rb b/test/controllers/api/v1/accounts_controller_test.rb index 65e1715cdec..722b821c7db 100644 --- a/test/controllers/api/v1/accounts_controller_test.rb +++ b/test/controllers/api/v1/accounts_controller_test.rb @@ -8,22 +8,14 @@ class Api::V1::AccountsControllerTest < ActionDispatch::IntegrationTest @other_family_user = users(:family_member) @other_family_user.update!(family: families(:empty)) - @user.api_keys.active.destroy_all - @api_key = ApiKey.create!( - user: @user, - name: "Test Read Key", - scopes: [ "read" ], - source: "web", - display_key: "test_read_#{SecureRandom.hex(8)}" - ) + Account.all.each do |account| + account.save! # trigger callback to set classification + end - @other_family_user.api_keys.active.destroy_all - @other_family_api_key = ApiKey.create!( - user: @other_family_user, - name: "Other Family Read Key", - scopes: [ "read" ], - source: "web", - display_key: "other_family_read_#{SecureRandom.hex(8)}" + @oauth_app = Doorkeeper::Application.create!( + name: "Test API App", + redirect_uri: "https://example.com/callback", + scopes: "read read_write" ) end diff --git a/test/controllers/api/v1/base_controller_test.rb b/test/controllers/api/v1/base_controller_test.rb index af9e7066dba..036b356c7cf 100644 --- a/test/controllers/api/v1/base_controller_test.rb +++ b/test/controllers/api/v1/base_controller_test.rb @@ -22,14 +22,6 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest display_key: @plain_api_key, scopes: [ "read_write" ] ) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - teardown do - # Clean up Redis data after each test - Redis.new.del("api_rate_limit:#{@api_key.id}") end test "should require authentication" do @@ -325,106 +317,6 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest assert_equal "forbidden", response_body["error"] end - test "should include rate limit headers on successful API key requests" do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - - assert_response :success - assert_not_nil response.headers["X-RateLimit-Limit"] - assert_not_nil response.headers["X-RateLimit-Remaining"] - assert_not_nil response.headers["X-RateLimit-Reset"] - - assert_equal "100", response.headers["X-RateLimit-Limit"] - assert_equal "99", response.headers["X-RateLimit-Remaining"] - end - - test "should increment rate limit count with each request" do - # First request - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - assert_equal "99", response.headers["X-RateLimit-Remaining"] - - # Second request - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - assert_equal "98", response.headers["X-RateLimit-Remaining"] - end - - test "should return 429 when rate limit exceeded" do - # Make 100 requests to exhaust the rate limit - 100.times do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - end - - # 101st request should be rate limited - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :too_many_requests - - response_body = JSON.parse(response.body) - assert_equal "rate_limit_exceeded", response_body["error"] - assert_includes response_body["message"], "Rate limit exceeded" - - # Check response headers - assert_equal "100", response.headers["X-RateLimit-Limit"] - assert_equal "0", response.headers["X-RateLimit-Remaining"] - assert_not_nil response.headers["X-RateLimit-Reset"] - assert_not_nil response.headers["Retry-After"] - end - - test "should not apply rate limiting to OAuth requests" do - # This would need to be implemented based on your OAuth setup - # For now, just verify that requests without API keys don't trigger rate limiting - get "/api/v1/test" - assert_response :unauthorized - - # Should not have rate limit headers for unauthorized requests - assert_nil response.headers["X-RateLimit-Limit"] - end - - test "should provide detailed rate limit information in 429 response" do - # Exhaust the rate limit - 100.times do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - end - - # Make the rate-limited request - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :too_many_requests - - response_body = JSON.parse(response.body) - assert_equal "rate_limit_exceeded", response_body["error"] - assert response_body["details"]["limit"] == 100 - assert response_body["details"]["current"] >= 100 - assert response_body["details"]["reset_in_seconds"] > 0 - end - - test "rate limiting should be per API key" do - # Create a second user for independent API keys - other_user = users(:family_member) - other_api_key = ApiKey.create!( - user: other_user, - name: "Other Test API Key", - scopes: [ "read" ], - display_key: "other_rate_test_#{SecureRandom.hex(8)}" - ) - - begin - # Make 50 requests with first API key - 50.times do - get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key } - assert_response :success - end - - # Should still be able to make requests with second API key - get "/api/v1/test", headers: { "X-Api-Key" => other_api_key.display_key } - assert_response :success - assert_equal "99", response.headers["X-RateLimit-Remaining"] - ensure - Redis.new.del("api_rate_limit:#{other_api_key.id}") - other_api_key.destroy - end - end - private def capture_log(&block) diff --git a/test/controllers/api/v1/transactions_controller_test.rb b/test/controllers/api/v1/transactions_controller_test.rb index 8ddb0ebf74a..61d96d49d85 100644 --- a/test/controllers/api/v1/transactions_controller_test.rb +++ b/test/controllers/api/v1/transactions_controller_test.rb @@ -27,10 +27,6 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest display_key: "test_ro_#{SecureRandom.hex(8)}", source: "mobile" # Use different source to allow multiple keys ) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") end # INDEX action tests diff --git a/test/controllers/api/v1/usage_controller_test.rb b/test/controllers/api/v1/usage_controller_test.rb index 27235826139..e169d51b385 100644 --- a/test/controllers/api/v1/usage_controller_test.rb +++ b/test/controllers/api/v1/usage_controller_test.rb @@ -12,14 +12,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest scopes: [ "read" ], display_key: "usage_test_#{SecureRandom.hex(8)}" ) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - teardown do - # Clean up Redis data after each test - Redis.new.del("api_rate_limit:#{@api_key.id}") end test "should return usage information for API key authentication" do @@ -40,14 +32,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest assert_equal [ "read" ], response_body["api_key"]["scopes"] assert_not_nil response_body["api_key"]["last_used_at"] assert_not_nil response_body["api_key"]["created_at"] - - # Check rate limit information - assert_equal "standard", response_body["rate_limit"]["tier"] - assert_equal 100, response_body["rate_limit"]["limit"] - assert_equal 4, response_body["rate_limit"]["current_count"] # 3 test requests + 1 usage request - assert_equal 96, response_body["rate_limit"]["remaining"] - assert response_body["rate_limit"]["reset_in_seconds"] > 0 - assert_not_nil response_body["rate_limit"]["reset_at"] end test "should require read scope for usage endpoint" do @@ -68,7 +52,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest response_body = JSON.parse(response.body) assert_equal "insufficient_scope", response_body["error"] ensure - Redis.new.del("api_rate_limit:#{api_key_no_read.id}") api_key_no_read.destroy end end @@ -79,58 +62,4 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest get "/api/v1/usage" assert_response :unauthorized end - - test "should update usage count when accessing usage endpoint" do - # Check initial state - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - response_body = JSON.parse(response.body) - first_count = response_body["rate_limit"]["current_count"] - - # Make another usage request - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - response_body = JSON.parse(response.body) - second_count = response_body["rate_limit"]["current_count"] - - assert_equal first_count + 1, second_count - end - - test "should include rate limit headers in usage response" do - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - assert_not_nil response.headers["X-RateLimit-Limit"] - assert_not_nil response.headers["X-RateLimit-Remaining"] - assert_not_nil response.headers["X-RateLimit-Reset"] - - assert_equal "100", response.headers["X-RateLimit-Limit"] - assert_equal "99", response.headers["X-RateLimit-Remaining"] - end - - test "should work correctly when approaching rate limit" do - # Make 98 requests to get close to the limit - 98.times do - get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - end - - # Check usage - this should be request 99 - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - response_body = JSON.parse(response.body) - assert_equal 99, response_body["rate_limit"]["current_count"] - assert_equal 1, response_body["rate_limit"]["remaining"] - - # One more request should hit the limit - get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :success - - # Now we should be rate limited - get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key } - assert_response :too_many_requests - end end diff --git a/test/integration/rack_attack_test.rb b/test/integration/rack_attack_test.rb index 37fc0b65e5d..c6069acc81b 100644 --- a/test/integration/rack_attack_test.rb +++ b/test/integration/rack_attack_test.rb @@ -20,4 +20,32 @@ class RackAttackTest < ActionDispatch::IntegrationTest throttles = Rack::Attack.throttles.keys assert_includes throttles, "api/requests", "API requests should have rate limiting" end + + test "scanner-style php paths are blocked" do + get "/ok.php" + + assert_response :forbidden + assert_equal({ "error" => "Request blocked." }, JSON.parse(response.body)) + end + + test "nested wordpress scanner paths are blocked" do + get "/wp-content/plugins/hellopress/wp_filemanager.php" + + assert_response :forbidden + assert_equal({ "error" => "Request blocked." }, JSON.parse(response.body)) + end + + test "php variant extensions are blocked" do + get "/ioxi001.PhP7" + + assert_response :forbidden + assert_equal({ "error" => "Request blocked." }, JSON.parse(response.body)) + end + + test "double-slash wordpress scanner paths are blocked" do + get "//wp-links-opml.php" + + assert_response :forbidden + assert_equal({ "error" => "Request blocked." }, JSON.parse(response.body)) + end end diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb index 7e180ef365e..ec946d34298 100644 --- a/test/models/balance/chart_series_builder_test.rb +++ b/test/models/balance/chart_series_builder_test.rb @@ -4,6 +4,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase include BalanceTestHelper setup do + Account.all.each do |account| + account.save! # trigger callback to set classification + end end test "balance series with fallbacks and gapfills" do diff --git a/test/services/api_rate_limiter_test.rb b/test/services/api_rate_limiter_test.rb deleted file mode 100644 index 8afc6bb9a8e..00000000000 --- a/test/services/api_rate_limiter_test.rb +++ /dev/null @@ -1,138 +0,0 @@ -require "test_helper" - -class ApiRateLimiterTest < ActiveSupport::TestCase - setup do - @user = users(:family_admin) - # Destroy any existing active API keys for this user - @user.api_keys.active.destroy_all - - @api_key = ApiKey.create!( - user: @user, - name: "Rate Limiter Test Key", - scopes: [ "read" ], - display_key: "rate_limiter_test_#{SecureRandom.hex(8)}" - ) - @rate_limiter = ApiRateLimiter.new(@api_key) - - # Clear any existing rate limit data - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - teardown do - # Clean up Redis data after each test - Redis.new.del("api_rate_limit:#{@api_key.id}") - end - - test "should have default rate limit" do - assert_equal 100, @rate_limiter.rate_limit - end - - test "should start with zero request count" do - assert_equal 0, @rate_limiter.current_count - end - - test "should not be rate limited initially" do - assert_not @rate_limiter.rate_limit_exceeded? - end - - test "should increment request count" do - assert_equal 0, @rate_limiter.current_count - - @rate_limiter.increment_request_count! - assert_equal 1, @rate_limiter.current_count - - @rate_limiter.increment_request_count! - assert_equal 2, @rate_limiter.current_count - end - - test "should be rate limited when exceeding limit" do - # Simulate reaching the rate limit - 100.times { @rate_limiter.increment_request_count! } - - assert_equal 100, @rate_limiter.current_count - assert @rate_limiter.rate_limit_exceeded? - end - - test "should provide correct usage info" do - 5.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - - assert_equal 5, usage_info[:current_count] - assert_equal 100, usage_info[:rate_limit] - assert_equal 95, usage_info[:remaining] - assert_equal :standard, usage_info[:tier] - assert usage_info[:reset_time] > 0 - assert usage_info[:reset_time] <= 3600 - end - - test "should calculate remaining requests correctly" do - 10.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - assert_equal 90, usage_info[:remaining] - end - - test "should have zero remaining when at limit" do - 100.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - assert_equal 0, usage_info[:remaining] - end - - test "should have zero remaining when over limit" do - 105.times { @rate_limiter.increment_request_count! } - - usage_info = @rate_limiter.usage_info - assert_equal 0, usage_info[:remaining] - end - - test "class method usage_for should work without incrementing" do - 5.times { @rate_limiter.increment_request_count! } - - usage_info = ApiRateLimiter.usage_for(@api_key) - assert_equal 5, usage_info[:current_count] - - # Should not increment when just checking usage - usage_info_again = ApiRateLimiter.usage_for(@api_key) - assert_equal 5, usage_info_again[:current_count] - end - - test "should handle multiple API keys separately" do - # Create a different user for the second API key - other_user = users(:family_member) - other_api_key = ApiKey.create!( - user: other_user, - name: "Other API Key", - scopes: [ "read_write" ], - display_key: "rate_limiter_other_#{SecureRandom.hex(8)}" - ) - - other_rate_limiter = ApiRateLimiter.new(other_api_key) - - @rate_limiter.increment_request_count! - other_rate_limiter.increment_request_count! - other_rate_limiter.increment_request_count! - - assert_equal 1, @rate_limiter.current_count - assert_equal 2, other_rate_limiter.current_count - ensure - Redis.new.del("api_rate_limit:#{other_api_key.id}") - other_api_key.destroy - end - - test "should calculate reset time correctly" do - reset_time = @rate_limiter.reset_time - - # Reset time should be within the current hour - assert reset_time > 0 - assert reset_time <= 3600 - - # Should be roughly the time until the next hour - current_time = Time.current.to_i - next_window = ((current_time / 3600) + 1) * 3600 - expected_reset = next_window - current_time - - assert_in_delta expected_reset, reset_time, 1 - end -end diff --git a/test/services/noop_api_rate_limiter_test.rb b/test/services/noop_api_rate_limiter_test.rb deleted file mode 100644 index 9c7105b12c8..00000000000 --- a/test/services/noop_api_rate_limiter_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -require "test_helper" - -class NoopApiRateLimiterTest < ActiveSupport::TestCase - setup do - @user = users(:family_admin) - # Clean up any existing API keys for this user to ensure tests start fresh - @user.api_keys.destroy_all - - @api_key = ApiKey.create!( - user: @user, - name: "Noop Rate Limiter Test Key", - scopes: [ "read" ], - display_key: "noop_rate_limiter_test_#{SecureRandom.hex(8)}" - ) - @rate_limiter = NoopApiRateLimiter.new(@api_key) - end - - test "should never be rate limited" do - assert_not @rate_limiter.rate_limit_exceeded? - end - - test "should not increment request count" do - @rate_limiter.increment_request_count! - assert_equal 0, @rate_limiter.current_count - end - - test "should always have zero request count" do - assert_equal 0, @rate_limiter.current_count - end - - test "should have infinite rate limit" do - assert_equal Float::INFINITY, @rate_limiter.rate_limit - end - - test "should have zero reset time" do - assert_equal 0, @rate_limiter.reset_time - end - - test "should provide correct usage info" do - usage_info = @rate_limiter.usage_info - - assert_equal 0, usage_info[:current_count] - assert_equal Float::INFINITY, usage_info[:rate_limit] - assert_equal Float::INFINITY, usage_info[:remaining] - assert_equal 0, usage_info[:reset_time] - assert_equal :noop, usage_info[:tier] - end - - test "class method usage_for should work" do - usage_info = NoopApiRateLimiter.usage_for(@api_key) - - assert_equal 0, usage_info[:current_count] - assert_equal Float::INFINITY, usage_info[:rate_limit] - assert_equal Float::INFINITY, usage_info[:remaining] - assert_equal 0, usage_info[:reset_time] - assert_equal :noop, usage_info[:tier] - end -end