diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 00000000..1dffb7e1 --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,58 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: false +:exclude_fixtures: false +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: false +:exclude_sti_subclasses: false +:exclude_tests: false +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:active_admin: false +:command: +:debug: false +:hide_default_column_types: "" +:hide_limit_column_types: "" +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: + - app/models +:require: [] +:root_dir: + - "" diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index 305c3f60..d3a00281 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Ruby and install gems uses: ruby/setup-ruby@v1 with: - ruby-version: "3.3.5" + ruby-version: "3.3.6" bundler-cache: true - name: Set up Node.js @@ -37,7 +37,6 @@ jobs: bundle exec rails db:create bundle exec rails db:migrate bundle exec rails db:schema:load - bundle exec rails db:seed - name: Set up certs run: | # multi-line run command: https://stackoverflow.com/a/66809682/6410635 diff --git a/.gitignore b/.gitignore index 7712e9f9..ffd8c4d2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /.bundle # Ignore vscode +/.idea /.vscode # Ignore all environment files (except templates). @@ -61,4 +62,11 @@ litestream/dbs/* wireguard.conf -!**/**/.keep \ No newline at end of file +newrelic.yml + +# Not using Terraform/Google Cloud +tf/ + +!**/**/.keep +# Sentry Config File +.env.sentry-build-plugin diff --git a/.husky/pre-commit b/.husky/pre-commit index 68a42507..20f99871 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -16,6 +16,4 @@ npx tsc --project tsconfig.json echo "Run rubocop on changed files, autofix any fixable offenses" bundle exec rubocop --autocorrect --only-recognized-file-types $changed_files -bundle exec annotate --models - git update-index --again \ No newline at end of file diff --git a/.node-version b/.node-version index bb8c76c6..dc0bb0f4 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v22.11.0 +v22.12.0 diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 22144e56..00000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -auto-install-peers=true -legacy-peer-deps=true diff --git a/.nvmrc b/.nvmrc index bb8c76c6..dc0bb0f4 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v22.11.0 +v22.12.0 diff --git a/.ruby-version b/.ruby-version index fa7adc7a..9c25013d 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.5 +3.3.6 diff --git a/Gemfile b/Gemfile index c6a459a8..2fd0cce0 100644 --- a/Gemfile +++ b/Gemfile @@ -2,16 +2,17 @@ source "https://rubygems.org" -ruby "3.3.5" +ruby "3.3.6" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" -gem "rails", "~> 7.2" +# gem "rails", "~> 8" +gem "rails", "~> 8" # The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] gem "sprockets-rails" # Use the Puma web server [https://github.com/puma/puma] -gem "puma", ">= 5.0" +gem "puma", ">= 5" # # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] # gem 'importmap-rails' @@ -67,7 +68,11 @@ gem "inertia_rails" # https://github.com/lostisland/faraday # https://medium.com/@zozulyak.nick/ruby-class-pattern-to-work-with-api-requests-with-built-in-async-approach-bf0713a7dc96 gem "concurrent-ruby" + +# https://github.com/lostisland/faraday gem "faraday" + +# https://github.com/mauricio/faraday_curl # gem 'faraday_curl' # https://github.com/cedarcode/webauthn-ruby @@ -86,7 +91,7 @@ gem "sqlite3", "~> 2", force_ruby_platform: true gem "sorbet-runtime" # gcp storage for get/put org icons, etc. -gem "google-cloud-storage", "~> 1.5" +gem "google-cloud-storage" # shorten invite urls # https://github.com/jpmcgrath/shortener @@ -98,9 +103,14 @@ gem "shortener" # https://medium.com/@dejanvu.developer/implementing-web-push-notifications-in-a-ruby-on-rails-application-dcd829e02df0 gem "web-push" -group :production do - gem "scout_apm" -end +# Logs in a single line +# https://github.com/roidrage/lograge +gem "lograge" + +gem "stackprof" +gem "sentry-ruby" +gem "sentry-rails" +gem "newrelic_rpm" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem @@ -133,7 +143,8 @@ group :development do # https://github.com/ctran/annotate_models # https://stackoverflow.com/questions/1289557/how-do-you-discover-model-attributes-in-rails - gem "annotate" + # Use annotaterb instead of annotate - https://github.com/drwl/annotaterb + gem "annotaterb" # Ruby type hints # https://sorbet.org/docs/adopting diff --git a/Gemfile.lock b/Gemfile.lock index 2df7942c..ce265f40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,66 +1,65 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.2.2) - actionpack (= 7.2.2) - activesupport (= 7.2.2) + actioncable (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.2) - actionpack (= 7.2.2) - activejob (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + actionmailbox (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) - actionmailer (7.2.2) - actionpack (= 7.2.2) - actionview (= 7.2.2) - activejob (= 7.2.2) - activesupport (= 7.2.2) + actionmailer (8.0.1) + actionpack (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.2) - actionview (= 7.2.2) - activesupport (= 7.2.2) + actionpack (8.0.1) + actionview (= 8.0.1) + activesupport (= 8.0.1) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.2) + 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.2) - actionpack (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + actiontext (8.0.1) + actionpack (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.2) - activesupport (= 7.2.2) + actionview (8.0.1) + activesupport (= 8.0.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.2) - activesupport (= 7.2.2) + activejob (8.0.1) + activesupport (= 8.0.1) globalid (>= 0.3.6) - activemodel (7.2.2) - activesupport (= 7.2.2) - activerecord (7.2.2) - activemodel (= 7.2.2) - activesupport (= 7.2.2) + activemodel (8.0.1) + activesupport (= 8.0.1) + activerecord (8.0.1) + activemodel (= 8.0.1) + activesupport (= 8.0.1) timeout (>= 0.4.0) - activestorage (7.2.2) - actionpack (= 7.2.2) - activejob (= 7.2.2) - activerecord (= 7.2.2) - activesupport (= 7.2.2) + activestorage (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activesupport (= 8.0.1) marcel (~> 1.0) - activesupport (7.2.2) + activesupport (8.0.1) base64 benchmark (>= 0.3) bigdecimal @@ -72,21 +71,19 @@ GEM minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) android_key_attestation (0.3.0) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) + annotaterb (4.13.0) ast (2.4.2) - awrence (1.2.1) base64 (0.2.0) - benchmark (0.3.0) + benchmark (0.4.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.1.8) + bigdecimal (3.1.9) bindata (2.5.0) bindex (0.8.1) binding_of_caller (1.0.1) @@ -111,9 +108,9 @@ GEM cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) crass (1.0.6) - csv (3.3.0) - date (3.4.0) - debug (1.9.2) + csv (3.3.2) + date (3.4.1) + debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) debug_inspector (1.2.0) @@ -121,10 +118,10 @@ GEM diff-lcs (1.5.1) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - dotenv (3.1.4) + dotenv (3.1.7) drb (2.2.1) dry-cli (1.2.0) - erubi (1.13.0) + erubi (1.13.1) factory_bot (6.5.0) activesupport (>= 5.0.0) factory_bot_rails (6.4.4) @@ -132,13 +129,13 @@ GEM railties (>= 5.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.0) - faraday-net_http (>= 2.0, < 3.4) + faraday (2.12.2) + faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.3.0) - net-http - geocoder (1.8.3) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) + geocoder (1.8.5) base64 (>= 0.1.0) csv (>= 3.0.0) globalid (1.2.1) @@ -153,7 +150,7 @@ GEM retriable (>= 2.0, < 4.a) google-apis-iamcredentials_v1 (0.22.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.47.0) + google-apis-storage_v1 (0.49.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.7.1) google-cloud-env (>= 1.0, < 3.a) @@ -161,7 +158,7 @@ GEM google-cloud-env (2.2.1) faraday (>= 1.0, < 3.a) google-cloud-errors (1.4.0) - google-cloud-storage (1.52.0) + google-cloud-storage (1.54.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (~> 0.13) @@ -170,9 +167,11 @@ GEM google-cloud-core (~> 1.6) googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.11.2) + google-logging-utils (0.1.0) + googleauth (1.12.2) faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.1) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) jwt (>= 1.4, < 3.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) @@ -180,22 +179,27 @@ GEM httpclient (2.8.3) i18n (1.14.6) concurrent-ruby (~> 1.0) - inertia_rails (3.4.0) + inertia_rails (3.6.0) railties (>= 6) - io-console (0.7.2) - irb (1.14.1) + io-console (0.8.0) + irb (1.14.3) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.7.5) - jwt (2.9.3) + json (2.9.1) + jwt (2.10.1) base64 language_server-protocol (3.17.0.3) lint_roller (1.1.0) - logger (1.6.1) - loofah (2.23.1) + logger (1.6.4) + lograge (0.14.0) + actionpack (>= 4) + activesupport (>= 4) + railties (>= 4) + request_store (~> 1.0) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -207,14 +211,14 @@ GEM matrix (0.4.2) method_source (1.1.0) mini_mime (1.1.5) - mini_portile2 (2.8.7) - minitest (5.25.1) - msgpack (1.7.3) + mini_portile2 (2.8.8) + minitest (5.25.4) + msgpack (1.7.5) multi_json (1.15.0) - mutex_m (0.2.0) - net-http (0.4.1) + mutex_m (0.3.0) + net-http (0.6.0) uri - net-imap (0.5.0) + net-imap (0.5.5) date net-protocol net-pop (0.1.2) @@ -224,56 +228,63 @@ GEM net-smtp (0.5.0) net-protocol netrc (0.11.0) + newrelic_rpm (9.16.1) nio4r (2.7.4) - nokogiri (1.16.7-aarch64-linux) + nokogiri (1.18.1-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.1-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.7-arm64-darwin) + nokogiri (1.18.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86_64-darwin) + nokogiri (1.18.1-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.16.7-x86_64-linux) + nokogiri (1.18.1-x86_64-linux-musl) racc (~> 1.4) - openssl (3.2.0) + openssl (3.3.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) os (1.1.4) parallel (1.26.3) - parser (3.3.5.1) + parser (3.3.6.0) ast (~> 2.4.1) racc - prism (1.2.0) - pry (0.14.2) + prism (1.3.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.1.2) + psych (5.2.2) + date stringio public_suffix (6.0.1) - puma (6.4.3) + puma (6.5.0) nio4r (~> 2.0) racc (1.8.1) rack (3.1.8) rack-proxy (0.7.7) rack - rack-session (2.0.0) + rack-session (2.1.0) + base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.0) + rackup (2.2.1) rack (>= 3) - rails (7.2.2) - actioncable (= 7.2.2) - actionmailbox (= 7.2.2) - actionmailer (= 7.2.2) - actionpack (= 7.2.2) - actiontext (= 7.2.2) - actionview (= 7.2.2) - activejob (= 7.2.2) - activemodel (= 7.2.2) - activerecord (= 7.2.2) - activestorage (= 7.2.2) - activesupport (= 7.2.2) + rails (8.0.1) + actioncable (= 8.0.1) + actionmailbox (= 8.0.1) + actionmailer (= 8.0.1) + actionpack (= 8.0.1) + actiontext (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activemodel (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) bundler (>= 1.15.0) - railties (= 7.2.2) + railties (= 8.0.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -282,12 +293,12 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.2.2) - actionpack (= 7.2.2) - activesupport (= 7.2.2) + 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) + railties (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -295,25 +306,27 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rbi (0.2.1) + rbi (0.2.2) prism (~> 1.0) sorbet-runtime (>= 0.5.9204) - rdoc (6.7.0) + rdoc (6.10.0) psych (>= 4.0.0) - regexp_parser (2.9.2) - reline (0.5.10) + regexp_parser (2.10.0) + reline (0.6.0) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) + request_store (1.7.0) + rack (>= 1.4) retriable (3.1.2) - rexml (3.3.9) + rexml (3.4.0) rgeo (3.0.1) rgeo-geojson (2.2.0) multi_json (~> 1.15) rgeo (>= 1.0.0) - rouge (4.4.0) + rouge (4.5.1) rspec-core (3.13.2) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) @@ -322,7 +335,7 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.0.1) + rspec-rails (7.1.0) actionpack (>= 7.0) activesupport (>= 7.0) railties (>= 7.0) @@ -332,48 +345,52 @@ GEM rspec-support (~> 3.13) rspec-sorbet (1.9.2) sorbet-runtime - rspec-support (3.13.1) - rubocop (1.66.1) + rspec-support (3.13.2) + rubocop (1.69.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.33.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) rubocop-factory_bot (2.26.1) rubocop (~> 1.61) - rubocop-performance (1.22.1) + rubocop-performance (1.23.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.27.0) + rubocop-rails (2.28.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.2.0) + rubocop-rspec (3.3.0) rubocop (~> 1.61) rubocop-shopify (2.15.1) rubocop (~> 1.51) - rubocop-thread_safety (0.5.1) - rubocop (>= 0.90.0) + rubocop-thread_safety (0.6.0) + rubocop (>= 1.48.1) ruby-progressbar (1.13.0) rubyzip (2.3.2) safety_net_attestation (0.4.0) jwt (~> 2.0) - scout_apm (5.4.0) - parser - securerandom (0.3.1) - selenium-webdriver (4.26.0) + securerandom (0.4.1) + selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) + sentry-rails (5.22.1) + railties (>= 5.0) + sentry-ruby (~> 5.22.1) + sentry-ruby (5.22.1) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) shortener (1.0.1) voight_kampff (~> 2.0) signet (0.19.0) @@ -381,15 +398,15 @@ GEM faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - sorbet (0.5.11633) - sorbet-static (= 0.5.11633) - sorbet-runtime (0.5.11633) - sorbet-static (0.5.11633-aarch64-linux) - sorbet-static (0.5.11633-universal-darwin) - sorbet-static (0.5.11633-x86_64-linux) - sorbet-static-and-runtime (0.5.11633) - sorbet (= 0.5.11633) - sorbet-runtime (= 0.5.11633) + sorbet (0.5.11718) + sorbet-static (= 0.5.11718) + sorbet-runtime (0.5.11718) + sorbet-static (0.5.11718-aarch64-linux) + sorbet-static (0.5.11718-universal-darwin) + sorbet-static (0.5.11718-x86_64-linux) + sorbet-static-and-runtime (0.5.11718) + sorbet (= 0.5.11718) + sorbet-runtime (= 0.5.11718) spoom (1.5.0) erubi (>= 1.10.0) prism (>= 0.28.0) @@ -402,22 +419,23 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - sqlite3 (2.2.0) + sqlite3 (2.5.0) mini_portile2 (~> 2.8.0) - standard (1.41.1) + stackprof (0.2.26) + standard (1.43.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.66.0) + rubocop (~> 1.69.1) standard-custom (~> 1.0.0) - standard-performance (~> 1.5) + standard-performance (~> 1.6) standard-custom (1.0.2) lint_roller (~> 1.0) rubocop (~> 1.50) - standard-performance (1.5.0) + standard-performance (1.6.0) lint_roller (~> 1.1) - rubocop-performance (~> 1.22.0) - stringio (3.1.1) - tapioca (0.16.3) + rubocop-performance (~> 1.23.0) + stringio (3.1.2) + tapioca (0.16.5) bundler (>= 2.2.25) netrc (>= 0.11.0) parallel (>= 1.21.0) @@ -427,28 +445,31 @@ GEM thor (>= 1.2.0) yard-sorbet thor (1.3.2) - timeout (0.4.1) + timeout (0.4.3) tpm-key_attestation (0.12.1) bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) trailblazer-option (0.1.2) - twilio-ruby (7.3.5) + twilio-ruby (7.4.0) faraday (>= 0.9, < 3.0) jwt (>= 1.5, < 3.0) nokogiri (>= 1.6, < 2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (2.6.0) - uri (0.13.1) - useragent (0.16.10) - vite_rails (3.0.18) + unicode-display_width (3.1.3) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.2) + useragent (0.16.11) + vite_rails (3.0.19) railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.9.0) + vite_ruby (3.9.1) dry-cli (>= 0.7, < 2) logger (~> 1.6) + mutex_m rack-proxy (~> 0.6, >= 0.6.1) zeitwerk (~> 2.2) voight_kampff (2.0.0) @@ -461,9 +482,8 @@ GEM web-push (3.0.1) jwt (~> 2.0) openssl (~> 3.0) - webauthn (3.1.0) + webauthn (3.2.2) android_key_attestation (~> 0.3.0) - awrence (~> 1.1) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) @@ -471,7 +491,8 @@ GEM safety_net_attestation (~> 0.4.0) tpm-key_attestation (~> 0.12.0) websocket (1.2.11) - websocket-driver (0.7.6) + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) @@ -494,7 +515,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - annotate + annotaterb better_errors binding_of_caller bootsnap @@ -506,12 +527,14 @@ DEPENDENCIES faker faraday geocoder - google-cloud-storage (~> 1.5) + google-cloud-storage inertia_rails jbuilder + lograge + newrelic_rpm pry - puma (>= 5.0) - rails (~> 7.2) + puma (>= 5) + rails (~> 8) rails-controller-testing rgeo rgeo-geojson @@ -524,13 +547,15 @@ DEPENDENCIES rubocop-rspec! rubocop-shopify! rubocop-thread_safety! - scout_apm selenium-webdriver + sentry-rails + sentry-ruby shortener sorbet sorbet-runtime sprockets-rails sqlite3 (~> 2) + stackprof standard! tapioca twilio-ruby @@ -541,7 +566,7 @@ DEPENDENCIES webauthn RUBY VERSION - ruby 3.3.5p100 + ruby 3.3.6p108 BUNDLED WITH - 2.5.22 + 2.5.23 diff --git a/app/controllers/admin/bills/creator_controller.rb b/app/controllers/admin/bills/creator_controller.rb index 1d390ed5..29f7abd9 100644 --- a/app/controllers/admin/bills/creator_controller.rb +++ b/app/controllers/admin/bills/creator_controller.rb @@ -6,7 +6,7 @@ class CreatorController < ApplicationController before_action :verify_is_admin def index - render inertia: "BillOfTheWeekCreator" + render inertia: "BillOfTheWeekCreatorPage" end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7787ddfa..9881389d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,6 +6,10 @@ class ApplicationController < ActionController::Base include RelyingParty include SwayProps + protect_from_forgery with: :exception + + newrelic_ignore_enduser + before_action :redirect_if_no_current_user before_action :set_sway_locale_id_in_session @@ -37,7 +41,7 @@ class ApplicationController < ActionController::Base BILL: "Bill", BILLS: "Bills", BILL_OF_THE_WEEK: "BillOfTheWeek", - BILL_CREATOR: "BillOfTheWeekCreator", + BILL_CREATOR: "BillOfTheWeekCreatorPage", INFLUENCE: "Influence", INVITE: "Invite", NOTIFICATIONS: "Notifications" @@ -57,8 +61,8 @@ def render_component(page, props = {}) if page == PAGES[:HOME] render inertia: page, props: else - # T.unsafe(self).route_home - redirect_to root_path + T.unsafe(self).route_home + # redirect_to root_path end elsif !u.is_registration_complete && page != PAGES[:REGISTRATION] # T.unsafe(self).route_registration @@ -80,16 +84,16 @@ def render_component(page, props = {}) props: T.untyped ).returns(T.untyped) end - def redirect_component(page, _props = {}) + def redirect_component(page, props = {}) redirect_to root_path if page.nil? u = current_user if u.nil? - redirect_to root_path + redirect_to root_path, inertia: props elsif !u.is_registration_complete && page != PAGES[:REGISTRATION] - redirect_to sway_registration_index_path + redirect_to sway_registration_index_path, inertia: props else - redirect_to send(T.cast(page, String)) + redirect_to send(T.cast(page, String)), inertia: props end end @@ -106,6 +110,7 @@ def route_component(route) render json: {route: ROUTES[:REGISTRATION], phone:} else Rails.logger.info "ServerRendering.route - Route to page - #{route}" + render json: {route:, phone:} end end @@ -142,7 +147,7 @@ def method_missing(method_name, *args) end @@_ssr_methods[method_name].call else - raise NoMethodError + raise NoMethodError.new("#{method_name} is not defined.") end end @@ -151,11 +156,6 @@ def respond_to_missing?(method_name, include_private = false) @@_ssr_methods.include?(method_name.to_sym) || super end - sig { void } - def test_recaptcha - # raise Errno::ECONNABORTED unless RecaptchaUtil.valid?(params[:token]) - end - private sig { params(user: T.nilable(User)).returns(T.untyped) } @@ -182,6 +182,8 @@ def sign_in(user) session[:sway_locale_id] ||= user.default_sway_locale&.id end + inertia_share flash: -> { flash.to_hash } + inertia_share do { user: current_user, diff --git a/app/controllers/bill_of_the_week_controller.rb b/app/controllers/bill_of_the_week_controller.rb index 55de74b3..510005e3 100644 --- a/app/controllers/bill_of_the_week_controller.rb +++ b/app/controllers/bill_of_the_week_controller.rb @@ -3,7 +3,7 @@ class BillOfTheWeekController < ApplicationController def index - b = T.cast(Bill.where(sway_locale: current_sway_locale).of_the_week, T.nilable(T.any(Bill, T::Array[Bill]))) + b = T.cast(Bill.of_the_week(sway_locale: current_sway_locale), T.nilable(T.any(Bill, T::Array[Bill]))) b = b.first if b.is_a?(Array) if b.present? diff --git a/app/controllers/bill_of_the_week_schedule_controller.rb b/app/controllers/bill_of_the_week_schedule_controller.rb new file mode 100644 index 00000000..ed0cdda2 --- /dev/null +++ b/app/controllers/bill_of_the_week_schedule_controller.rb @@ -0,0 +1,33 @@ +class BillOfTheWeekScheduleController < ApplicationController + before_action :set_bill, only: %i[update] + + def update + redirect_path = @bill.nil? ? new_bill_path : edit_bill_path(@bill.id, tabKey: params[:tab_key]) + new_release = bill_of_the_week_schedule_params[:scheduled_release_date_utc] + + if @bill.present? + if @bill.update(scheduled_release_date_utc: new_release) + flash[:notice] = new_release.blank? ? "Bill - #{@bill.title} - removed from schedule." : "Added bill - #{@bill.title} - to schedule." + redirect_to redirect_path + else + flash[:alert] = "Failed to update bill schedule." + redirect_to redirect_path, inertia: { + errors: @bill.errors + } + end + else + flash[:alert] = "Failed to update bill schedule. Bill not found." + redirect_to redirect_path + end + end + + private + + def bill_of_the_week_schedule_params + params.require(:bill_of_the_week_schedule).permit(:bill_id, :scheduled_release_date_utc, :tab_key) + end + + def set_bill + @bill = Bill.includes(:sway_locale).find(bill_of_the_week_schedule_params[:bill_id]) + end +end diff --git a/app/controllers/bills_controller.rb b/app/controllers/bills_controller.rb index d7001bad..0c3777a6 100644 --- a/app/controllers/bills_controller.rb +++ b/app/controllers/bills_controller.rb @@ -36,52 +36,73 @@ def new l.to_builder.attributes! end, legislatorVotes: [], - positions: [] + organizations: Organization.where(sway_locale: current_sway_locale).map { |o| o.to_builder(with_positions: false).attributes! }, + tabKey: params[:tab_key] }) end # GET /bills/1/edit def edit - return if @bill.blank? + redirect_to new_bill_path if @bill.blank? T.unsafe(self).render_bill_creator({ bills: (current_sway_locale&.bills || []).map { |b| b.to_builder.attributes! }, - bill: @bill.to_builder.attributes!, + bill: @bill.to_builder.attributes!.tap do |b| + b[:organizations] = @bill.organizations.map do |organization| + organization.to_builder(with_positions: true).attributes! + end + end, legislators: (current_sway_locale&.legislators || []).map do |l| l.to_builder.attributes! end, legislatorVotes: @bill.legislator_votes.map { |lv| lv.to_builder.attributes! }, - positions: @bill.organization_bill_positions.map do |obp| - obp.to_builder.attributes! - end + organizations: Organization.where(sway_locale: current_sway_locale).map { |o| o.to_builder(with_positions: false).attributes! }, + tabKey: params[:tab_key] }) end # POST /bills or /bills.json def create - b = Bill.find_or_create_by!( - **bill_params, - sway_locale: current_sway_locale + b = Bill.find_or_initialize_by( + external_id: bill_params[:external_id], + sway_locale_id: bill_params[:sway_locale_id] || current_sway_locale.presence&.id ) - create_vote(b) + b.assign_attributes(**bill_params.except(*vote_params)) - render json: b.to_builder.attributes!, status: :ok + b.legislator = Legislator.find(bill_params[:legislator_id]) + + if b.save + create_vote(b) + + redirect_to edit_bill_path(b.id, {saved: "Bill Created", event_key: "legislator_votes"}), inertia: {errors: {}} + else + redirect_to new_bill_path({event_key: "bill"}), inertia: { + errors: b.errors + } + end + rescue Exception => e # rubocop:disable Lint/RescueException + Rails.logger.error(e) + redirect_to new_bill_path({event_key: "bill"}), inertia: {errors: {external_id: e}} end # PATCH/PUT /bills/1 or /bills/1.json def update - render json: {success: false, message: @bill.errors.join(", ")}, status: :ok if @bill.blank? + if @bill.blank? + return redirect_to new_bill_path({event_key: "bill"}) + end current_audio_path = @bill.audio_bucket_path.freeze - if @bill.update(bill_params) + if @bill.update(bill_params.except(*vote_params)) remove_audio(current_audio_path) create_vote(@bill) - render json: @bill.to_builder.attributes!, status: :ok + redirect_to edit_bill_path(@bill.id, {saved: "Bill Updated", event_key: "legislator_votes"}) else - render json: {success: false, message: @bill.errors.join(", ")}, status: :ok + redirect_to edit_bill_path(@bill.id, {event_key: "bill"}), inertia: { + errors: b.errors + } end end @@ -94,7 +115,6 @@ def destroy private - # Use callbacks to share common setup or constraints between actions. def set_bill @bill = Bill.includes(:legislator_votes, :organization_bill_positions, :legislator, :sway_locale).find(params[:id]) end @@ -109,23 +129,16 @@ def remove_audio(audio_path) end def create_vote(b) - return unless vote_params[:house_roll_call_vote_number] || vote_params[:senate_roll_call_vote_number] + return unless bill_params[:house_roll_call_vote_number] || bill_params[:senate_roll_call_vote_number] Vote.find_or_create_by!(bill_id: b.id, - house_roll_call_vote_number: vote_params[:house_roll_call_vote_number], - senate_roll_call_vote_number: vote_params[:senate_roll_call_vote_number]) - end - - def vote_params - params.permit( - :house_roll_call_vote_number, - :senate_roll_call_vote_number - ) + house_roll_call_vote_number: bill_params[:house_roll_call_vote_number], + senate_roll_call_vote_number: bill_params[:senate_roll_call_vote_number]) end # Only allow a list of trusted parameters through. def bill_params - params.require(:bill).permit( + params.transform_keys(&:underscore).permit( :external_id, :external_version, :title, @@ -134,7 +147,6 @@ def bill_params :introduced_date_time_utc, :house_vote_date_time_utc, :senate_vote_date_time_utc, - :chamber, :category, :level, :summary, @@ -142,8 +154,43 @@ def bill_params :active, :audio_bucket_path, :audio_by_line, + :sway_locale_id, :legislator_id, - :sway_locale_id + :house_roll_call_vote_number, + :senate_roll_call_vote_number, + :audio_bucket_path, + :audio_by_line ) end + + def vote_params + [ + :house_roll_call_vote_number, + :senate_roll_call_vote_number + ] + end + + def legislator_vote_params + params.require(:legislator_votes).map do |p| + p.transform_keys(&:underscore).permit(:legislator_id, :bill_id, :support) + end + end + + def organizations_params + params.require(:organizations).map do |p| + p.transform_keys(&:underscore).permit( + :id, :sway_locale_id, :name, :icon_path, positions: [:id, :bill_id, :summary, :support] + ) + end + end + + def params + super.transform_keys(&:underscore) + end + + def remove_icon(organization, current_icon_path) + return unless organization.icon_path != current_icon_path + + delete_file(bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], file_name: current_icon_path) + end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index b5a908da..bb6a62bd 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -51,6 +51,7 @@ def send_phone_verification(session, phone_) true rescue Twilio::REST::RestError => e Rails.logger.error e.full_message + Sentry.capture_exception(e) false end end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index cc61ef5b..e6170bb1 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -7,7 +7,12 @@ class HomeController < ApplicationController def index u = current_user if u.nil? - T.unsafe(self).render_home({name: "Sway", isBubbles: true}) + render inertia: PAGES[:HOME], props: { + name: "Sway", + isBubbles: true, + params: params + } + # T.unsafe(self).render_home({name: "Sway", isBubbles: true}) elsif u.is_registration_complete redirect_to legislators_path else diff --git a/app/controllers/legislator_votes_controller.rb b/app/controllers/legislator_votes_controller.rb index fbf323b3..ea356285 100644 --- a/app/controllers/legislator_votes_controller.rb +++ b/app/controllers/legislator_votes_controller.rb @@ -2,34 +2,45 @@ # typed: true class LegislatorVotesController < ApplicationController - before_action :verify_is_admin, only: %i[create update] + before_action :verify_is_admin, only: %i[create] + before_action :set_bill, only: %i[show create] def index render json: current_user&.legislators(T.cast(current_sway_locale, SwayLocale)), status: :ok end def show - render json: LegislatorVote.where(bill_id: legislator_votes_params[:bill_id]).map { |lv| - lv.to_builder.attributes! - }, status: :ok + render json: LegislatorVote.where(bill: @bill).map { |lv| lv.to_builder.attributes! }, status: :ok end def create - LegislatorVote.where(bill_id: legislator_votes_params[:votes].first[:bill_id]).destroy_all - - render json: LegislatorVote.insert_all!(legislator_votes_params[:votes]), status: :ok # rubocop:disable Rails/SkipsModelValidations - end - - def update + LegislatorVote.where(bill: @bill).destroy_all + + begin + legislator_votes_params[:legislator_votes].each do |param| + LegislatorVote.create!({ + bill_id: @bill.id, + legislator_id: param[:legislator_id].to_i, + support: param[:support] + }) + end + rescue Exception => e # rubocop:disable Lint/RescueException + Rails.logger.error(e) + redirect_to edit_bill_path(@bill.id, {event_key: "legislator_votes"}), inertia: { + errors: {legislator_votes: e} + } + else + redirect_to edit_bill_path(@bill.id, {saved: "Legislator Votes Saved", event_key: "organizations"}) + end end private - def legislator_votes_params - params.require(:legislator_vote).permit(votes: %i[bill_id legislator_id support]) + def set_bill + @bill = Bill.includes(:legislator_votes, :sway_locale).find(legislator_votes_params[:bill_id]) end - def legislator_vote_params - params.require(:legislator_vote).permit(:bill_id, :legislator_id, :support) + def legislator_votes_params + params.permit(:bill_id, legislator_votes: %i[legislator_id support]) end end diff --git a/app/controllers/legislators_controller.rb b/app/controllers/legislators_controller.rb index ed8c7353..95270fe2 100644 --- a/app/controllers/legislators_controller.rb +++ b/app/controllers/legislators_controller.rb @@ -20,7 +20,7 @@ def show private def json_legislators - current_user&.user_legislators&.map do |ul| + current_user&.user_legislators&.joins(:legislator)&.where(active: true, legislators: {active: true})&.map do |ul| ul.legislator.to_builder.attributes! end end diff --git a/app/controllers/notifications/push_notifications_controller.rb b/app/controllers/notifications/push_notifications_controller.rb index b5a52cc9..6c2a6f5a 100644 --- a/app/controllers/notifications/push_notifications_controller.rb +++ b/app/controllers/notifications/push_notifications_controller.rb @@ -7,6 +7,7 @@ class PushNotificationsController < ApplicationController before_action :set_subscription + # Allow the user to test a push notification def create SwayPushNotificationService.new( @subscription, diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 702546e5..062331cc 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -4,8 +4,9 @@ class OrganizationsController < ApplicationController include SwayGoogleCloudStorage - before_action :verify_is_admin, only: %i[create update] - before_action :set_organization, only: %i[show update] + before_action :verify_is_admin, only: %i[create] + before_action :set_organization, only: %i[show] + before_action :set_bill, only: %i[create] def index render json: Organization.where(sway_locale_id: current_sway_locale&.id).map { |o| @@ -22,35 +23,75 @@ def show end def create - render json: Organization.find_or_create_by!(**organization_params, sway_locale: current_sway_locale).to_builder(with_positions: false).attributes!, - status: :ok - end + errored = false + errors = { + organizations: organizations_params[:organizations].map do |_| + { + label: nil, + value: nil, + summary: nil, + support: nil, + icon_path: nil + } + end + } - def update - if @organization.present? - current_icon_path = @organization.icon_path.freeze - @organization.update!(organization_params) - remove_icon(current_icon_path) + organizations_params[:organizations].each_with_index do |param, index| + organization = find_or_initialize_organization_from_param(param) + current_icon_path = organization.icon_path.freeze + organization.icon_path = param[:icon_path] + + if organization.save + organization.remove_icon(current_icon_path) + + position = OrganizationBillPosition.find_or_initialize_by(organization:, bill: @bill) + position.support = param[:support] + position.summary = param[:summary] + + unless position.save + errored = true + position.errors.each do |e| + if errors[:organizations][index].key?(e.attribute) + errors[:organizations][index][e.attribute] = "#{e.attribute.capitalize} #{e.message}" + end + end + end + else + errored = true + organization.errors.each do |e| + attribute_by_key = { + name: :label, + id: :value, + icon_path: :icon_path + } - render json: @organization.to_builder(with_positions: false).attributes!, status: :ok + attr = attribute_by_key[e.attribute] + if attr.present? && errors[:organizations][index].key?(attr) + errors[:organizations][index][attr] = "#{attr} #{e.message.capitalize}" + end + end + end + end + + if errored + redirect_to edit_bill_path(@bill.id, {event_key: "organizations"}), inertia: {errors: errors} else - render json: {success: false, message: "Organization not found."}, status: :ok + redirect_to edit_bill_path(@bill.id, {saved: "Supporting/Opposing Arguments Saved", event_key: "organizations"}) end end private - def set_organization - @organization = Organization.find_by(id: params[:id]) + def set_bill + @bill = Bill.includes(:organization_bill_positions, :sway_locale).find(organizations_params[:bill_id]) end - def organization_params - params.require(:organization).permit(:name, :icon_path, :sway_locale_id) + def organizations_params + params.permit(:bill_id, organizations: %i[label value summary support icon_path]) end - def remove_icon(current_icon_path) - return unless @organization.icon_path != current_icon_path - - delete_file(bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], file_name: current_icon_path) + def find_or_initialize_organization_from_param(param) + o = param[:value].blank? ? nil : Organization.find_by(id: param[:value]).presence + o || Organization.find_or_initialize_by(name: param[:label], sway_locale: @bill.sway_locale) end end diff --git a/app/controllers/phone_verification_controller.rb b/app/controllers/phone_verification_controller.rb index d2bc9a20..66a62bb5 100644 --- a/app/controllers/phone_verification_controller.rb +++ b/app/controllers/phone_verification_controller.rb @@ -7,7 +7,6 @@ class PhoneVerificationController < ApplicationController include Authentication before_action :set_twilio_client - before_action :test_recaptcha, only: %i[create update] skip_before_action :redirect_if_no_current_user def create diff --git a/app/controllers/sway_registration_controller.rb b/app/controllers/sway_registration_controller.rb index 5b84c0f3..8a3a5cfe 100644 --- a/app/controllers/sway_registration_controller.rb +++ b/app/controllers/sway_registration_controller.rb @@ -28,9 +28,9 @@ def index def create u = current_user if u.nil? - T.unsafe(self).route_home + redirect_to root_path elsif u.has_user_legislators? - T.unsafe(self).route_legislators + redirect_to legislators_path else T.cast(user_address(u).address, Address).sway_locales.each do |sway_locale| SwayRegistrationService.new( @@ -42,9 +42,13 @@ def create end if u.is_registration_complete - T.unsafe(self).route_legislators + redirect_to legislators_path else - T.unsafe(self).route_registration + redirect_to sway_registration_index_path, inertia: { + errros: { + address: "Registration not complete." + } + } end end end diff --git a/app/controllers/user_legislators_controller.rb b/app/controllers/user_legislators_controller.rb index e4e5a828..f86a7532 100644 --- a/app/controllers/user_legislators_controller.rb +++ b/app/controllers/user_legislators_controller.rb @@ -18,7 +18,8 @@ def create invited_by_id: session[UserInviter::INVITED_BY_SESSION_KEY] ).run - T.unsafe(self).route_legislators + # T.unsafe(self).route_legislators + redirect_to legislators_path end private diff --git a/app/controllers/user_votes_controller.rb b/app/controllers/user_votes_controller.rb index 859f201e..ab1e0d51 100644 --- a/app/controllers/user_votes_controller.rb +++ b/app/controllers/user_votes_controller.rb @@ -21,17 +21,21 @@ def show # POST /user_votes or /user_votes.json def create - render json: UserVote.find_or_create_by!( + uv = UserVote.find_or_create_by( user: current_user, bill_id: user_vote_params[:bill_id], support: user_vote_params[:support] - ).to_json, status: :ok + ) + + redirect_to user_vote_params[:redirect_to], inertia: { + errors: uv.errors + } end private # Only allow a list of trusted parameters through. def user_vote_params - params.require(:user_vote).permit(:bill_id, :support) + params.permit(:bill_id, :support, :redirect_to) end end diff --git a/app/controllers/users/webauthn/registration_controller.rb b/app/controllers/users/webauthn/registration_controller.rb index d3943b9e..817f4ceb 100644 --- a/app/controllers/users/webauthn/registration_controller.rb +++ b/app/controllers/users/webauthn/registration_controller.rb @@ -7,7 +7,6 @@ module Webauthn class RegistrationController < ApplicationController extend T::Sig - before_action :test_recaptcha, only: [:create] skip_before_action :redirect_if_no_current_user def create @@ -18,6 +17,7 @@ def create user.is_phone_verified = session[:verified_phone] == session[:phone] if user.is_phone_verified + create_options = relying_party.options_for_registration( user: { name: session[:verified_phone], @@ -93,7 +93,7 @@ def challenge sig { returns(ActionController::Parameters) } def registration_params - params.require(:registration).permit(:passkey_label, :token) + params.permit(:passkey_label, :token) end end end diff --git a/app/controllers/users/webauthn/sessions_controller.rb b/app/controllers/users/webauthn/sessions_controller.rb index f314902e..b3900239 100644 --- a/app/controllers/users/webauthn/sessions_controller.rb +++ b/app/controllers/users/webauthn/sessions_controller.rb @@ -8,7 +8,6 @@ class SessionsController < ApplicationController extend T::Sig include Authentication - before_action :test_recaptcha, only: [:create] skip_before_action :redirect_if_no_current_user def create @@ -76,16 +75,17 @@ def destroy private def session_params - params.require(:session).permit(:phone, :publicKeyCredential, :token) + params.permit(:phone, :publicKeyCredential, :token) end def public_key_credential_params - # params.require(:session).require(:publicKeyCredential).permit(:type, :id, :rawId, :authenticatorAttachment, + # params.require(:publicKeyCredential).permit(:type, :id, :rawId, :authenticatorAttachment, # :response, :userHandle, :clientExtensionResults) - params.require(:session).require(:publicKeyCredential) + params.require(:publicKeyCredential) end sig { returns(T.nilable(String)) } + def phone session_params[:phone]&.remove_non_digits end diff --git a/app/frontend/components/Layout.tsx b/app/frontend/components/Layout.tsx index 8f2c3cd5..7c7e683a 100644 --- a/app/frontend/components/Layout.tsx +++ b/app/frontend/components/Layout.tsx @@ -11,11 +11,13 @@ const Layout_: React.FC = ({ children, ...props }) => ( {React.Children.map(children, (child, i) => ( - {React.isValidElement(child) ? ( - React.cloneElement(child, { ...child?.props, ...props }) - ) : ( - - )} +
+ {React.isValidElement(child) ? ( + React.cloneElement(child, { ...(child.props as Record), ...props }) + ) : ( + + )} +
))} diff --git a/app/frontend/components/SwayLogo.tsx b/app/frontend/components/SwayLogo.tsx index b5c66f0b..476be9c7 100644 --- a/app/frontend/components/SwayLogo.tsx +++ b/app/frontend/components/SwayLogo.tsx @@ -1,4 +1,4 @@ -const SwayLogo = ({ className, maxWidth }: { className?: string; maxWidth?: number }) => { +const SwayLogo = ({ className, maxWidth }: { className?: string; maxWidth?: number | string }) => { return ( void; -} - -const BillCreatorOrganizations: React.FC = ({ swayFieldName, error, handleSetTouched }) => { - const { - items: organizations, - get: getOrganizations, - isLoading: isLoadingOrganizations, - } = useAxiosGet("/organizations", { - skipInitialRequest: false, - notifyOnValidationResultFailure: true, - }); - - const [formikField, , { setValue: setFieldValue }] = useField(swayFieldName); - - const options = useMemo( - () => (organizations ?? []).map((o) => ({ label: o.name, value: o.id, summary: "", iconPath: o.iconPath })), - [organizations], - ); - const handleSelectOrganization = useCallback( - (newValues: MultiValue) => { - if (newValues) { - setFieldValue(newValues as TOrganizationOption[]).catch(console.error); - } - }, - [setFieldValue], - ); - - const { post: createOrganization } = useAxiosPost("/organizations", { - notifyOnValidationResultFailure: true, - }); - - const mappedSelectedOrgs = useMemo( - () => - formikField.value.filter(Boolean).map((option, index, array) => { - const isLastOrganization = index === array.length - 1; - - return ( - - - {isLastOrganization ? null :
} -
- ); - }), - [formikField.value, swayFieldName, handleSetTouched, error], - ); - - const handleCreateOption = useCallback( - (name: string) => { - createOrganization({ name }) - .then((result) => { - if (result?.id) { - setFieldValue(formikField.value.concat({ label: name, value: result.id, summary: "" })).catch( - console.error, - ); - } - getOrganizations().catch(console.error); - }) - .catch(handleError); - }, - [createOrganization, formikField.value, getOrganizations, setFieldValue], - ); - - return ( -
-
-
- - {formikField.name.toLowerCase().includes("oppose") ? "Opposing" : "Supporting"} Organizations - {" (Optional)"} - - -
-
-
-
{mappedSelectedOrgs}
-
-
- ); -}; - -export default BillCreatorOrganizations; diff --git a/app/frontend/components/admin/creator/BillCreatorAccordions.tsx b/app/frontend/components/admin/creator/BillCreatorAccordions.tsx new file mode 100644 index 00000000..76508df3 --- /dev/null +++ b/app/frontend/components/admin/creator/BillCreatorAccordions.tsx @@ -0,0 +1,87 @@ +import { router, usePage } from "@inertiajs/react"; +import BillCreatorLegislatorVotes from "app/frontend/components/admin/creator/BillCreatorLegislatorVotes"; +import BillCreatorOrganizations from "app/frontend/components/admin/creator/BillCreatorOrganizations"; +import BillCreatorBill from "app/frontend/components/admin/creator/BillCreatorBill"; +import { EEventKey } from "app/frontend/components/bill/creator/constants"; +import { PropsWithChildren, useCallback } from "react"; +import { Accordion } from "react-bootstrap"; +import { sway } from "sway"; + +interface IProps { + setCreatorDirty: React.Dispatch>; +} + +const BillCreatorAccordions: React.FC = ({ setCreatorDirty }) => { + const bill = usePage().props.bill as sway.IBill; + const event_key = new URLSearchParams(window.location.search).get("event_key"); + + const setEventKey = useCallback( + (eventKey: EEventKey) => { + const params = new URLSearchParams(window.location.search); + if (eventKey === event_key) { + params.delete("event_key", eventKey); + } else { + params.set("event_key", eventKey); + } + router.get(`${window.location.origin}${window.location.pathname}?${params.toString()}`); + }, + [event_key], + ); + + return ( + + + + Details and Summary + + + + + + + + + 0)} + > + Legislator Votes + + + + + + + + + 0)} + > + Supporting/Opposing Arguments + + + + + + + + ); +}; + +function AccordionButton({ + children, + eventKey, + disabled: _disabled, + onClick, +}: PropsWithChildren & { eventKey: EEventKey; disabled?: boolean; onClick: (eKey: EEventKey) => void }) { + return ( + onClick(eventKey)} className="py-4 fs-4"> + {children} + + ); +} + +export default BillCreatorAccordions; diff --git a/app/frontend/components/admin/creator/BillCreatorBill.tsx b/app/frontend/components/admin/creator/BillCreatorBill.tsx new file mode 100644 index 00000000..1d826e16 --- /dev/null +++ b/app/frontend/components/admin/creator/BillCreatorBill.tsx @@ -0,0 +1,199 @@ +import { Button, Form } from "react-bootstrap"; +import { FiSave } from "react-icons/fi"; + +import SwaySpinner from "app/frontend/components/SwaySpinner"; + +import BillCreatorFormHeader from "app/frontend/components/admin/creator/BillCreatorFormHeader"; +import DateField from "app/frontend/components/admin/creator/fields/DateField"; +import SelectField from "app/frontend/components/admin/creator/fields/SelectField"; +import SummaryField from "app/frontend/components/admin/creator/fields/SummaryField"; +import TextField from "app/frontend/components/admin/creator/fields/TextField"; +import { useNewBillInitialValues } from "app/frontend/components/admin/creator/hooks/useNewBillInitialValues"; +import { useTempStorage } from "app/frontend/components/admin/creator/hooks/useTempStorage"; +import { IApiBillCreator } from "app/frontend/components/admin/creator/types"; +import { BILL_INPUTS } from "app/frontend/components/bill/creator/inputs"; +import FormContext from "app/frontend/components/contexts/FormContext"; +import { useLocale } from "app/frontend/hooks/useLocales"; +import { useSearchParams } from "app/frontend/hooks/useSearchParams"; +import { notify, SWAY_STORAGE } from "app/frontend/sway_utils"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { sway } from "sway"; +import { useInertiaForm } from "use-inertia-form"; + +interface IProps { + setCreatorDirty: React.Dispatch>; +} + +const BillCreatorBill = ({ setCreatorDirty }: IProps) => { + const [swayLocale] = useLocale(); + const initialValues = useNewBillInitialValues(); + const form = useInertiaForm(initialValues); + const summaryRef = useRef(""); + + const { + entries: { saved }, + remove, + } = useSearchParams(); + useEffect(() => { + if (saved) { + notify({ level: "success", title: saved }); + window.setTimeout(() => { + remove("saved"); + }, 2000); + } + }, [saved, remove]); + + useEffect(() => { + setCreatorDirty(form.isDirty); + }, [setCreatorDirty, form.isDirty]); + + const onSubmit: React.FormEventHandler = useCallback( + (e) => { + e.preventDefault(); + + form.transform((data) => { + return { + ...data, + summary: summaryRef.current, + status: (typeof form.data.status === "string" + ? form.data.status + : form.data.status?.value) as sway.TBillStatus, + category: (typeof form.data.category === "string" + ? form.data.category + : form.data.category?.value) as sway.TBillCategory, + chamber: ((typeof form.data.chamber === "string" ? form.data.chamber : form.data.chamber?.value) || + "council") as sway.TBillChamber, + legislator_id: (typeof form.data.legislator_id === "number" + ? form.data.legislator_id + : form.data.legislator_id?.value) as number, + sway_locale_id: swayLocale.id, + }; + }); + + const caller = initialValues.id ? form.put : form.post; + const route = initialValues.id ? `/bills/${initialValues.id}` : "/bills"; + + caller(route); + }, + [form, initialValues.id, swayLocale.id], + ); + + const toStore = useMemo( + () => ({ + ...form.data, + summary: summaryRef.current || "", + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [form.data, summaryRef.current], + ); + + const { storage, onBlur, blurredFieldName } = useTempStorage(SWAY_STORAGE.Local.BillOfTheWeek.Bill, toStore); + + return ( + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ } + onBlur={onBlur} + ref={summaryRef} + /> +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+ ); +}; + +export default BillCreatorBill; diff --git a/app/frontend/components/admin/creator/BillCreatorFields.tsx b/app/frontend/components/admin/creator/BillCreatorFields.tsx deleted file mode 100644 index d4d528a6..00000000 --- a/app/frontend/components/admin/creator/BillCreatorFields.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import SwayLogo from "app/frontend/components/SwayLogo"; -import BillCreatorOrganizations from "app/frontend/components/admin/BillCreatorOrganizations"; -import BillCreatorSummaryAudio from "app/frontend/components/admin/BillCreatorSummaryAudio"; -import { ISubmitValues } from "app/frontend/components/admin/types"; -import BillCreatorSummary from "app/frontend/components/bill/BillCreatorSummary"; -import { BILL_INPUTS } from "app/frontend/components/bill/creator/inputs"; -import SwaySelect from "app/frontend/components/forms/SwaySelect"; -import SwayText from "app/frontend/components/forms/SwayText"; -import SwayTextArea from "app/frontend/components/forms/SwayTextArea"; -import { useLocale } from "app/frontend/hooks/useLocales"; -import { CONGRESS_LOCALE_NAME } from "app/frontend/sway_constants"; -import { - REACT_SELECT_STYLES, - isCongressLocale, - logDev, - titleize, - toFormattedLocaleName, - toSelectOption, -} from "app/frontend/sway_utils"; -import { useFormikContext } from "formik"; -import { get, isEmpty, sortBy } from "lodash"; -import { forwardRef, useCallback, useMemo } from "react"; -import { Form } from "react-bootstrap"; -import DatePicker from "react-datepicker"; -import Select, { Options } from "react-select"; -import { ISelectOption, sway } from "sway"; - -interface IProps { - legislators: sway.ILegislator[]; -} - -const BillCreatorFields = forwardRef(({ legislators }: IProps, summaryRef: React.Ref) => { - const [locale] = useLocale(); - - const { values, touched, errors, setFieldValue, setTouched } = useFormikContext(); - - if (!isEmpty(errors)) { - logDev("BillOfTheWeekCreator.renderFields - ERRORS", errors); - } - - const handleSetTouched = useCallback( - (fieldname: string) => { - if ((touched as Record)[fieldname]) return; - setTouched({ - ...touched, - [fieldname]: true, - }).catch(console.error); - }, - [setTouched, touched], - ); - - const errorMessage = useCallback( - (fieldname: string): string => { - if (!fieldname || !errors || !(touched as Record)[fieldname]) return ""; - - const error = get(errors, fieldname); - if (!error) return ""; - - if (Array.isArray(error)) { - return (error as string[]).find((e) => e === fieldname) || ""; - } else { - return error as string; - } - }, - [errors, touched], - ); - - const generateValues = useCallback( - (swayField: sway.IFormField) => { - if (swayField.component === "generatedText" && swayField.generateFields) { - return swayField.generateFields - .map((fieldname: string) => (values as Record)[fieldname]) - .join(swayField.joiner || " "); - } - return ""; - }, - [values], - ); - - const legislatorOptions = useMemo( - () => - sortBy( - (legislators ?? []).map((l: sway.ILegislator) => ({ - label: `${titleize(l.lastName)}, ${titleize(l.firstName)} (${l.district.regionCode} - ${ - l.district.number - })`, - value: l.id, - })), - ["label"], - ), - [legislators], - ); - - const assignPossibleValues = useCallback( - (swayField: sway.IFormField): ISelectOption[] => { - if (swayField.name === "legislator") { - return legislatorOptions; - } else if (swayField.name === "chamber") { - if (isCongressLocale(locale)) { - return [toSelectOption("house", "house"), toSelectOption("senate", "senate")]; - } else { - return [toSelectOption("council", "council")]; - } - } else if (["supporters", "opposers", "abstainers"].includes(swayField.name)) { - const selectedSupporter = get(values, "supporters") || []; - const selectedOpposer = get(values, "opposers") || []; - const selectedAbstainer = get(values, "abstainers") || []; - const selected = selectedSupporter.concat(selectedOpposer).concat(selectedAbstainer); - - return legislatorOptions.filter((o) => !selected.find((s) => s.value === o.value)); - } else { - return swayField.possibleValues || []; - } - }, - [legislatorOptions, locale, values], - ); - - const isRenderPositionsSelects = useCallback( - (swayField: sway.IFormField) => { - if (["supporters", "opposers", "abstainers"].includes(swayField.name)) { - return locale.name !== CONGRESS_LOCALE_NAME; - } else { - return true; - } - }, - [locale.name], - ); - - return useMemo(() => { - const render = [] as React.ReactNode[]; - let i = 0; - while (i < BILL_INPUTS.length) { - const fieldGroup = BILL_INPUTS[i]; - - const row = []; - for (const swayField of fieldGroup) { - const generatedValue = generateValues(swayField); - - const { component } = swayField; - - if (swayField.name === "senateVoteDateTimeUtc" && !isCongressLocale(locale)) { - continue; - } - - if (component === "separator") { - row.push( -
- -
, - ); - } else if (["text", "generatedText"].includes(component)) { - const value = - component === "text" ? (values as Record)[swayField.name] : generatedValue; - - row.push( -
= 4 ? 12 / fieldGroup.length : 4}`} - > - -
, - ); - } else if (component === "select") { - if (swayField.name.startsWith("organizations")) { - row.push( -
- )[swayField.name] as string | undefined} - handleSetTouched={handleSetTouched} - /> -
, - ); - } else if (swayField.name === "localeName") { - row.push( -
= 4 ? 12 / fieldGroup.length : 4}`} - > - null} - setFieldValue={(fname, fvalue) => { - setFieldValue(fname, fvalue).catch(console.error); - }} - value={toSelectOption(toFormattedLocaleName(locale.name), locale.id)} - helperText={swayField.helperText} - /> -
, - ); - } else { - swayField.possibleValues = assignPossibleValues(swayField); - - const getValue = (v: any) => { - if (Array.isArray(v)) { - return val.map((_v: any) => getValue(_v)); - } else if (["string", "number"].includes(typeof v)) { - return (swayField.possibleValues || []).find((item) => item.value === v); - } else { - return v; - } - }; - - const { name } = swayField; - const val = (values as Record)[swayField.name]; - const value = getValue(val); - - row.push( - = 4 ? 12 / fieldGroup.length : 4}`} - > - {swayField.label && ( - - {swayField.label} - {swayField.isRequired ? " *" : " (Optional)"} - - )} - } + isMulti={Boolean(swayField.multi)} + isDisabled={!isRenderPositionsSelects(swayField) || swayField.disabled || swayField.disableOn?.(locale)} + value={value} + onChange={(changed) => { + setData(name, changed); + }} + closeMenuOnSelect={!["supporters", "opposers", "abstainers"].includes(name)} + /> +
{errorMessage}
+
+ ); +}; + +export default SelectField; diff --git a/app/frontend/components/admin/creator/fields/Separator.tsx b/app/frontend/components/admin/creator/fields/Separator.tsx new file mode 100644 index 00000000..35e15dc6 --- /dev/null +++ b/app/frontend/components/admin/creator/fields/Separator.tsx @@ -0,0 +1,12 @@ +import { IFieldProps } from "app/frontend/components/admin/creator/types"; +import SwayLogo from "app/frontend/components/SwayLogo"; + +const Separator = ({ swayField }: IFieldProps) => { + return ( +
+ +
+ ); +}; + +export default Separator; diff --git a/app/frontend/components/admin/creator/fields/SummaryField.tsx b/app/frontend/components/admin/creator/fields/SummaryField.tsx new file mode 100644 index 00000000..0080f04c --- /dev/null +++ b/app/frontend/components/admin/creator/fields/SummaryField.tsx @@ -0,0 +1,39 @@ +import BillCreatorSummaryAudio from "app/frontend/components/admin/creator/BillCreatorSummaryAudio"; +import BillCreatorSummary from "app/frontend/components/bill/BillCreatorSummary"; +import { useLocale } from "app/frontend/hooks/useLocales"; +import { forwardRef, Ref, useMemo } from "react"; +import { Form } from "react-bootstrap"; +import { sway } from "sway"; + +interface IProps { + swayField: sway.IFormField; + onBlur?: (e: React.FocusEvent) => void; +} + +const SummaryField = ({ swayField, onBlur }: IProps, summaryRef: React.Ref) => { + const [locale] = useLocale(); + + const field: sway.IFormField = useMemo( + () => ({ + ...swayField, + disabled: Boolean(swayField.disabled || swayField.disableOn?.(locale)), + }), + [locale, swayField], + ); + + return ( + + + {swayField.label} + {swayField.isRequired ? " *" : " (Optional)"} + + + + + ); +}; + +// https://stackoverflow.com/a/78692562/6410635 +export default forwardRef(SummaryField) as ( + props: IProps & { ref?: Ref }, +) => ReturnType; diff --git a/app/frontend/components/admin/creator/fields/TextAreaField.tsx b/app/frontend/components/admin/creator/fields/TextAreaField.tsx new file mode 100644 index 00000000..78e1a515 --- /dev/null +++ b/app/frontend/components/admin/creator/fields/TextAreaField.tsx @@ -0,0 +1,37 @@ +import { useErrorMessage } from "app/frontend/components/admin/creator/hooks/useErrorMessage"; +import { IFieldProps } from "app/frontend/components/admin/creator/types"; +import { useFormContext } from "app/frontend/components/contexts/hooks/useFormContext"; +import SwayTextArea from "app/frontend/components/forms/SwayTextArea"; +import { useLocale } from "app/frontend/hooks/useLocales"; +import { Form } from "react-bootstrap"; + +const TextAreaField = ({ swayField, fieldGroupLength }: IFieldProps) => { + const [locale] = useLocale(); + const errorMessage = useErrorMessage(swayField); + const { data } = useFormContext(); + + return ( + = 4 ? 12 / fieldGroupLength : 4}`} + > + + {swayField.label} + {swayField.isRequired ? " *" : " (Optional)"} + + )[swayField.name] || ""} + error={errorMessage} + helperText={swayField.helperText} + rows={swayField.rows} + /> + + ); +}; + +export default TextAreaField; diff --git a/app/frontend/components/admin/creator/fields/TextField.tsx b/app/frontend/components/admin/creator/fields/TextField.tsx new file mode 100644 index 00000000..0d6e69a4 --- /dev/null +++ b/app/frontend/components/admin/creator/fields/TextField.tsx @@ -0,0 +1,43 @@ +import { useErrorMessage } from "app/frontend/components/admin/creator/hooks/useErrorMessage"; +import { IFieldProps } from "app/frontend/components/admin/creator/types"; +import { useFormContext } from "app/frontend/components/contexts/hooks/useFormContext"; +import SwayText from "app/frontend/components/forms/SwayText"; +import { useLocale } from "app/frontend/hooks/useLocales"; +import { useMemo } from "react"; + +const TextField = ({ swayField, fieldGroupLength, onBlur }: IFieldProps) => { + const { data } = useFormContext(); + const [locale] = useLocale(); + const errorMessage = useErrorMessage(swayField); + + const generatedValue = useMemo(() => { + if (swayField.component === "generatedText" && swayField.generateFields) { + return swayField.generateFields + .map((fieldname: string) => (data as Record)[fieldname]) + .join(swayField.joiner || " "); + } + return ""; + }, [data, swayField]); + + const value = swayField.component === "text" ? (data as Record)[swayField.name] : generatedValue; + + return ( +
= 4 ? 12 / fieldGroupLength : 4}`} + > + +
+ ); +}; + +export default TextField; diff --git a/app/frontend/components/admin/creator/hooks/useAssignValues.ts b/app/frontend/components/admin/creator/hooks/useAssignValues.ts new file mode 100644 index 00000000..06f29ca0 --- /dev/null +++ b/app/frontend/components/admin/creator/hooks/useAssignValues.ts @@ -0,0 +1,37 @@ +import { usePage } from "@inertiajs/react"; +import { useLocale } from "app/frontend/hooks/useLocales"; +import { isCongressLocale, titleize, toSelectOption } from "app/frontend/sway_utils"; +import { sortBy } from "lodash"; +import { useMemo } from "react"; +import { ISelectOption, sway } from "sway"; + +export const useAssignValues = (swayField: sway.IFormField) => { + const [locale] = useLocale(); + const legislators = usePage().props.legislators as sway.ILegislator[]; + + const legislatorOptions = useMemo( + () => + sortBy( + (legislators ?? []).map((l: sway.ILegislator) => ({ + label: `${titleize(l.lastName)}, ${titleize(l.firstName)} (${l.district.regionCode} - ${ + l.district.number + })`, + value: l.id, + })), + ["label"], + ), + [legislators], + ) as ISelectOption[]; + + if (swayField.name === "legislator_id") { + swayField.possibleValues = legislatorOptions; + } else if (swayField.name === "bill.chamber") { + if (isCongressLocale(locale)) { + swayField.possibleValues = [toSelectOption("house", "house"), toSelectOption("senate", "senate")]; + } else { + swayField.possibleValues = [toSelectOption("council", "council")]; + } + } else { + swayField.possibleValues = swayField.possibleValues || []; + } +}; diff --git a/app/frontend/components/admin/creator/hooks/useErrorMessage.ts b/app/frontend/components/admin/creator/hooks/useErrorMessage.ts new file mode 100644 index 00000000..0364980a --- /dev/null +++ b/app/frontend/components/admin/creator/hooks/useErrorMessage.ts @@ -0,0 +1,24 @@ +import { useFormContext } from "app/frontend/components/contexts/hooks/useFormContext"; +import { logDev } from "app/frontend/sway_utils"; +import { get } from "lodash"; +import { useMemo } from "react"; +import { sway } from "sway"; + +export const useErrorMessage = (swayField: sway.IFormField) => { + const { errors } = useFormContext(); + + return useMemo(() => { + if (!swayField?.name || !errors) return ""; + + const error = get(errors, swayField.name); + if (!error) return ""; + + logDev("BillCreatorField.errorMessage -", { error, fieldname: swayField.name }); + + if (Array.isArray(error)) { + return (error as string[]).find((e) => e === swayField.name) || ""; + } else { + return error as string; + } + }, [errors, swayField]); +}; diff --git a/app/frontend/components/admin/creator/hooks/useNewBillInitialValues.ts b/app/frontend/components/admin/creator/hooks/useNewBillInitialValues.ts new file mode 100644 index 00000000..01c92895 --- /dev/null +++ b/app/frontend/components/admin/creator/hooks/useNewBillInitialValues.ts @@ -0,0 +1,67 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +/** @format */ +import { ESwayLevel } from "app/frontend/sway_constants"; +import { isCongressLocale, SWAY_STORAGE, toSelectOption } from "app/frontend/sway_utils"; +import { useMemo } from "react"; +import { sway } from "sway"; + +import { usePage } from "@inertiajs/react"; +import { IApiBillCreator } from "app/frontend/components/admin/creator/types"; +import { TempBillStorage } from "app/frontend/components/bill/creator/TempBillStorage"; +import { useLocale } from "app/frontend/hooks/useLocales"; + +export const useNewBillInitialValues = (): IApiBillCreator => { + const [locale] = useLocale(); + const bill = usePage().props.bill as sway.IBill; + const legislators = usePage().props.legislators as sway.ILegislator[]; + const organizations = usePage().props.organizations as sway.IOrganization[]; + + const initialBill = useMemo( + () => ({ + id: bill.id, + external_id: bill?.externalId?.trim() || "", + external_version: bill?.externalVersion?.trim() || "", + title: bill?.title?.trim() || "", + link: bill?.link?.trim() || "", + legislator_id: bill?.legislatorId || null, + chamber: + bill?.chamber || + (isCongressLocale(locale) ? toSelectOption("house", "house") : toSelectOption("Council", "council")), + level: bill?.level?.trim() || ESwayLevel.Local, + summary: bill?.summary?.trim() ?? "", + summary_preview: bill?.summary?.trim() ?? "", + category: bill?.category ?? "", + status: bill?.status?.trim() ?? "", + active: typeof bill?.active === "boolean" ? bill.active : true, + + introduced_date_time_utc: bill?.introducedDateTimeUtc ? new Date(bill?.introducedDateTimeUtc) : null, + withdrawn_date_time_utc: bill?.withdrawnDateTimeUtc ? new Date(bill?.withdrawnDateTimeUtc) : null, + house_vote_date_time_utc: bill?.houseVoteDateTimeUtc ? new Date(bill?.houseVoteDateTimeUtc) : null, + senate_vote_date_time_utc: bill?.senateVoteDateTimeUtc ? new Date(bill?.senateVoteDateTimeUtc) : null, + + sway_locale_id: locale.id, + + audio_bucket_path: bill?.audioBucketPath?.trim() || "", + audio_by_line: bill?.audioByLine?.trim() || "", + + house_roll_call_vote_number: bill?.vote?.houseRollCallVoteNumber ?? "", + senate_roll_call_vote_number: bill?.vote?.senateRollCallVoteNumber ?? "", + }), + [bill, locale], + ); + + return useMemo(() => { + const stored = new TempBillStorage(SWAY_STORAGE.Local.BillOfTheWeek.Bill).get(); + if (stored) { + return stored; + } else { + return initialBill; + // bill: initialBill, + // sponsor: legislators.find((l) => l.id === bill.legislatorId), + // legislatorVotes, + // organizations, + // } as ISubmitValues; + } + }, [initialBill, legislators, organizations]); +}; diff --git a/app/frontend/components/admin/creator/hooks/useTempStorage.ts b/app/frontend/components/admin/creator/hooks/useTempStorage.ts new file mode 100644 index 00000000..6bf83dda --- /dev/null +++ b/app/frontend/components/admin/creator/hooks/useTempStorage.ts @@ -0,0 +1,32 @@ +import { TempBillStorage } from "app/frontend/components/bill/creator/TempBillStorage"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +export const useTempStorage = (key: string, data: Record) => { + const storage = useMemo(() => new TempBillStorage(key), [key]); + + const [blurredFieldName, setBlurredFieldName] = useState(""); + + const storeTempData = useCallback(() => { + storage.set(data); + }, [storage, data]); + + const onBlur = useCallback( + (e: React.FocusEvent) => { + storeTempData(); + setBlurredFieldName(e.target.name); + }, + [storeTempData], + ); + + useEffect(() => { + const t = window.setTimeout(() => { + setBlurredFieldName(""); + }, 3000); + + return () => { + window.clearTimeout(t); + }; + }, []); + + return { storage, blurredFieldName, onBlur }; +}; diff --git a/app/frontend/components/admin/creator/types/index.ts b/app/frontend/components/admin/creator/types/index.ts new file mode 100644 index 00000000..e01d8e67 --- /dev/null +++ b/app/frontend/components/admin/creator/types/index.ts @@ -0,0 +1,39 @@ +import { ISelectOption, KeyOf, sway } from "sway"; + +export interface IFieldProps { + swayField: sway.IFormField; + onBlur?: (e: React.FocusEvent) => void; + fieldGroupLength: number; +} + +export interface IApiBillCreator extends Omit { + chamber: sway.IApiBill["chamber"] | ISelectOption; + category: sway.IApiBill["category"] | ISelectOption; + status: sway.IApiBill["status"] | ISelectOption; + legislator_id: sway.IApiBill["legislator_id"] | ISelectOption; + house_roll_call_vote_number: string | number; + senate_roll_call_vote_number: string | number; +} + +export interface IApiLegislatorVote { + legislator_id: number; + support: sway.TLegislatorSupport; +} + +export interface ICreatorLegislatorVotes { + FOR: IApiLegislatorVote[]; + AGAINST: IApiLegislatorVote[]; + ABSTAIN: IApiLegislatorVote[]; +} + +export type TOrganizationOption = ISelectOption & { summary: string; support: sway.TUserSupport; icon_path?: string }; + +export type TOrganizationError = Record, string> | undefined; +export interface IOrganizationErrors { + organizations: TOrganizationError[]; +} + +export interface ICreatorOrganizations { + bill_id: number; + organizations: TOrganizationOption[]; +} diff --git a/app/frontend/components/admin/types.ts b/app/frontend/components/admin/types.ts index fdb69db8..a62fa663 100644 --- a/app/frontend/components/admin/types.ts +++ b/app/frontend/components/admin/types.ts @@ -1,26 +1,57 @@ import { ISelectOption, sway } from "sway"; -export type TOrganizationOption = ISelectOption & { summary: string; iconPath?: string }; - -export type ISubmitValues = sway.IBill & { - legislator: ISelectOption; - - supporters: ISelectOption[]; - opposers: ISelectOption[]; - abstainers: ISelectOption[]; - - category: ISelectOption; - status: ISelectOption; - chamber: ISelectOption; - - // organizations: IDataOrganizationPosition[]; - - audioBucketPath?: string; - audioByLine?: string; - - houseRollCallVoteNumber?: number; - senateRollCallVoteNumber?: number; - - organizationsSupport: TOrganizationOption[]; - organizationsOppose: TOrganizationOption[]; -}; +export interface ISubmitValues { + bill: Omit< + sway.IBill, + | "category" + | "chamber" + | "status" + | "level" + | "active" + | "scheduledReleaseDateUtc" + | "voteDateTimeUtc" + | "vote" + | "introducedDateTimeUtc" + | "withdrawnDateTimeUtc" + | "houseVoteDateTimeUtc" + | "senateVoteDateTimeUtc" + > & { + summaryPreview: string; + } & { + chamber: string | ISelectOption; + } & { + vote: Omit; + } & { + category: sway.TBillCategory | ISelectOption; + status: sway.TBillStatus | ISelectOption; + } & { + introducedDateTimeUtc: string | null; + withdrawnDateTimeUtc: string | null; + houseVoteDateTimeUtc: string | null; + senateVoteDateTimeUtc: string | null; + }; + sponsor: sway.ILegislator | ISelectOption | null; +} + +// export type ISubmitValues = sway.IBill & { +// legislator: ISelectOption; + +// supporters: ISelectOption[]; +// opposers: ISelectOption[]; +// abstainers: ISelectOption[]; + +// category: ISelectOption; +// status: ISelectOption; +// chamber: ISelectOption; + +// // organizations: IDataOrganizationPosition[]; + +// audioBucketPath?: string; +// audioByLine?: string; + +// houseRollCallVoteNumber?: number; +// senateRollCallVoteNumber?: number; + +// organizationsSupport: TOrganizationOption[]; +// organizationsOppose: TOrganizationOption[]; +// }; diff --git a/app/frontend/components/bill/BillArguments.tsx b/app/frontend/components/bill/BillArguments.tsx index 427b1930..92b9b42d 100644 --- a/app/frontend/components/bill/BillArguments.tsx +++ b/app/frontend/components/bill/BillArguments.tsx @@ -8,14 +8,28 @@ import BillSummaryModal from "./BillSummaryModal"; interface IProps { bill: sway.IBill; - organizationPositions: sway.IOrganizationPosition[]; + organizations: sway.IOrganization[]; } -const BillArguments: React.FC = ({ organizationPositions }) => { +const BillArguments: React.FC = ({ bill, organizations }) => { const [selectedOrganization, setSelectedOrganization] = useState(); const [supportSelected, setSupportSelected] = useState(0); const [opposeSelected, setOpposeSelected] = useState(0); + const organizationPositions = useMemo( + () => + (organizations || []) + .map((o) => { + const position = o.positions.find((p) => p.billId === bill.id); + return { + organization: o, + ...position, + }; + }) + .filter((o) => !!o?.support && !!o.summary), + [organizations, bill], + ) as (sway.IOrganizationPosition & { organization: sway.IOrganization })[]; + const supportingOrgs = useMemo( () => organizationPositions.filter((o) => o.support === Support.For), [organizationPositions], @@ -26,7 +40,7 @@ const BillArguments: React.FC = ({ organizationPositions }) => { ); const mapper = useCallback( - (organizationPosition: sway.IOrganizationPosition, index: number) => { + (organizationPosition: sway.IOrganizationPosition & { organization: sway.IOrganization }, index: number) => { return ( = ({ organizationPositions }) => { ); const renderOrgs = useCallback( - (positions: sway.IOrganizationPosition[], title: string) => ( + (positions: (sway.IOrganizationPosition & { organization: sway.IOrganization })[], title: string) => (
{title}
{isEmpty(positions) ? "None" : positions.map(mapper)}
@@ -53,12 +67,12 @@ const BillArguments: React.FC = ({ organizationPositions }) => { ); const renderOrgSummary = useCallback( - (position: sway.IOrganizationPosition, title: string) => ( + (organization: sway.IOrganization, position: sway.IOrganizationPosition, title: string) => (
{title} @@ -76,13 +90,13 @@ const BillArguments: React.FC = ({ organizationPositions }) => {
{renderOrgs(supportingOrgs, "Supporting Organizations")} - {renderOrgSummary(supportingOrg, "Supporting Argument")} + {renderOrgSummary(supportingOrg.organization, supportingOrg, "Supporting Argument")}
{renderOrgs(opposingOrgs, "Opposing Organizations")} - {renderOrgSummary(opposingOrg, "Opposing Argument")} + {renderOrgSummary(opposingOrg.organization, opposingOrg, "Opposing Argument")}
@@ -95,8 +109,8 @@ const BillArguments: React.FC = ({ organizationPositions }) => { {renderOrgs(opposingOrgs, "Opposing Organizations")}
- {renderOrgSummary(supportingOrg, "Supporting Argument")} - {renderOrgSummary(opposingOrg, "Opposing Argument")} + {renderOrgSummary(supportingOrg.organization, supportingOrg, "Supporting Argument")} + {renderOrgSummary(opposingOrg.organization, opposingOrg, "Opposing Argument")}
); diff --git a/app/frontend/components/bill/BillArgumentsOrganization.tsx b/app/frontend/components/bill/BillArgumentsOrganization.tsx index 98d92b28..9abe8994 100644 --- a/app/frontend/components/bill/BillArgumentsOrganization.tsx +++ b/app/frontend/components/bill/BillArgumentsOrganization.tsx @@ -1,5 +1,4 @@ import ButtonUnstyled from "app/frontend/components/ButtonUnstyled"; -import OrganizationIcon from "app/frontend/components/organizations/OrganizationIcon"; import { Support } from "app/frontend/sway_constants"; import { Image } from "react-bootstrap"; import { sway } from "sway"; @@ -29,17 +28,17 @@ const BillArgumentsOrganization: React.FC = ({ return (
- {organizationPosition.organization.iconPath ? ( + {/* {organizationPosition.organization.iconPath ? ( - ) : ( - - )} + ) : ( */} + + {/* )} */}
); diff --git a/app/frontend/components/bill/BillComponent.tsx b/app/frontend/components/bill/BillComponent.tsx index 2b49287b..70c48620 100644 --- a/app/frontend/components/bill/BillComponent.tsx +++ b/app/frontend/components/bill/BillComponent.tsx @@ -22,27 +22,29 @@ const BillActionLinks = lazy(() => import("app/frontend/components/bill/BillActi interface IProps { bill: sway.IBill; - positions: sway.IOrganizationPosition[]; + organizations: sway.IOrganization[]; legislatorVotes: sway.ILegislatorVote[]; sponsor: sway.ILegislator; locale?: sway.ISwayLocale; userVote?: sway.IUserVote; } -const DEFAULT_ORGANIZATION_POSITION: sway.IOrganizationPosition = { +const DEFAULT_ORGANIZATION: sway.IOrganization = { id: -1, - billId: -1, - support: Support.Abstain, - summary: "", - organization: { - id: -1, - swayLocaleId: -1, - name: "Sway", - iconPath: "sway.png", - }, + swayLocaleId: -1, + name: "Sway", + iconPath: "sway.png", + positions: [ + { + id: -1, + billId: -1, + support: Support.Abstain, + summary: "", + }, + ], }; -const BillComponent: React.FC = ({ bill, sponsor, positions, userVote }) => { +const BillComponent: React.FC = ({ bill, sponsor, organizations, userVote }) => { const user = useUser(); const [locale] = useLocale(); @@ -176,7 +178,7 @@ const BillComponent: React.FC = ({ bill, sponsor, positions, userVote }) @@ -186,7 +188,7 @@ const BillComponent: React.FC = ({ bill, sponsor, positions, userVote })
- +
diff --git a/app/frontend/components/bill/BillCreatorSummary.tsx b/app/frontend/components/bill/BillCreatorSummary.tsx index fed66f85..828fe04e 100644 --- a/app/frontend/components/bill/BillCreatorSummary.tsx +++ b/app/frontend/components/bill/BillCreatorSummary.tsx @@ -1,54 +1,42 @@ -import { logDev } from "app/frontend/sway_utils"; -import { useField } from "formik"; -import { forwardRef, useCallback, useEffect, useState } from "react"; +import { useFormContext } from "app/frontend/components/contexts/hooks/useFormContext"; +import { withEmojis } from "app/frontend/sway_utils"; +import { forwardRef, Ref, useCallback, useEffect, useState } from "react"; import { sway } from "sway"; -import { handleError } from "../../sway_utils"; -import { withEmojis } from "../../sway_utils/emoji"; import SwayTextArea from "../forms/SwayTextArea"; import BillSummaryMarkdown from "./BillSummaryMarkdown"; +import { IApiBillCreator } from "app/frontend/components/admin/creator/types"; -interface IProps { - field: sway.IFormField; +interface IProps { + field: sway.IFormField; + onBlur?: (e: React.FocusEvent) => void; } +const BillCreatorSummary = ({ field, onBlur }: IProps, ref: React.Ref) => { + const { data, errors } = useFormContext(); + const summaryRef = ref as React.MutableRefObject; -const BillCreatorSummary = forwardRef(({ field }: IProps, ref: React.Ref) => { - const [formikField] = useField(field.name); - const [summary, setSummary] = useState(formikField.value || ""); - - const handleSetSummary = useCallback(async (_fieldname: string, string: string) => { - withEmojis(string) - .then((emojis) => { - setSummary(emojis); - }) - .catch(console.error); + const [summary, setSummary] = useState(data.summary ?? ""); + const onChange = useCallback((e: React.ChangeEvent) => { + setSummary(withEmojis(e.target.value)); }, []); useEffect(() => { - // @ts-expect-error - weird ref types - ref.current = summary; - }, [ref, summary]); - - useEffect(() => { - if (formikField.value) { - logDev("BillCreatorSummary.useEffect - set summary"); - handleSetSummary("", formikField.value).catch(handleError); - } - }, [formikField.value, handleSetSummary]); + summaryRef.current = summary; + }, [summaryRef, summary]); return (
-
- + + field={field} value={summary} - error={""} - setFieldValue={handleSetSummary} - handleSetTouched={() => null} + onChange={onChange} + error={errors["summary"]} helperText={field.helperText} + onBlur={onBlur} />
-
+ ); -}); +}; -export default BillCreatorSummary; +// https://stackoverflow.com/a/78692562/6410635 +export default forwardRef(BillCreatorSummary) as ( + props: IProps & { ref?: Ref }, +) => ReturnType; diff --git a/app/frontend/components/bill/BillSummaryModal.tsx b/app/frontend/components/bill/BillSummaryModal.tsx index 811828b4..12a85106 100644 --- a/app/frontend/components/bill/BillSummaryModal.tsx +++ b/app/frontend/components/bill/BillSummaryModal.tsx @@ -12,19 +12,17 @@ const DialogWrapper = lazy(() => import("../dialogs/DialogWrapper")); interface IProps { summary: string; - organizationPosition: sway.IOrganizationPosition | undefined; + organization: sway.IOrganization | undefined; selectedOrganization: sway.IOrganizationBase | undefined; setSelectedOrganization: (org: sway.IOrganizationBase | undefined) => void; } const BillSummaryModal: React.FC = ({ summary, - organizationPosition, + organization, selectedOrganization, setSelectedOrganization, }) => { - const organization = useMemo(() => organizationPosition?.organization, [organizationPosition?.organization]); - const isSelected = useMemo( () => organization?.name && organization?.name === selectedOrganization?.name, [organization?.name, selectedOrganization?.name], diff --git a/app/frontend/components/bill/BillSummaryTextWithLink.tsx b/app/frontend/components/bill/BillSummaryTextWithLink.tsx index 391db6ce..743c57bb 100644 --- a/app/frontend/components/bill/BillSummaryTextWithLink.tsx +++ b/app/frontend/components/bill/BillSummaryTextWithLink.tsx @@ -24,7 +24,7 @@ const extractAnchorTextFromString = (string: string): [string, string, string][] const BillSummaryTextWithLink: React.FC<{ text: string }> = ({ text }) => { const matches = extractAnchorTextFromString(text); - let final: (string | JSX.Element)[] = []; + let final: (string | React.JSX.Element)[] = []; matches.forEach(([anchor, href, innerText], index: number) => { const toReplace = (final.pop() || text) as string; const replacer = toReplace.split(anchor); diff --git a/app/frontend/components/bill/BillsListItem.tsx b/app/frontend/components/bill/BillsListItem.tsx index a98cf78e..58d3ef27 100644 --- a/app/frontend/components/bill/BillsListItem.tsx +++ b/app/frontend/components/bill/BillsListItem.tsx @@ -1,15 +1,16 @@ /** @format */ import { IS_MOBILE_PHONE, ROUTES } from "app/frontend/sway_constants"; import { titleize } from "app/frontend/sway_utils"; -import { lazy, useCallback } from "react"; +import { lazy, Suspense, useCallback } from "react"; import { Button, Fade } from "react-bootstrap"; import { FiInfo } from "react-icons/fi"; import { sway } from "sway"; -import { router } from "@inertiajs/react"; -import SuspenseFullScreen from "app/frontend/components/dialogs/SuspenseFullScreen"; +import { Link as InertiaLink, router } from "@inertiajs/react"; +import CenteredLoading from "app/frontend/components/dialogs/CenteredLoading"; import LocaleAvatar from "app/frontend/components/locales/LocaleAvatar"; +import { useAxios_NOT_Authenticated_GET } from "app/frontend/hooks/useAxios"; import { useLocale } from "app/frontend/hooks/useLocales"; import VoteButtonsContainer from "../uservote/VoteButtonsContainer"; import { BillChartFilters } from "./charts/constants"; @@ -19,16 +20,17 @@ const BillChartsContainer = lazy(() => import("./charts/BillChartsContainer")); interface IProps { bill: sway.IBill; organizations?: sway.IOrganization[]; - userVote?: sway.IUserVote; index: number; isLastItem: boolean; inView: boolean; } -const BillsListItem: React.FC = ({ bill, userVote, isLastItem, inView }) => { +const BillsListItem: React.FC = ({ bill, isLastItem, inView }) => { const [locale] = useLocale(); - const { category, externalId, title } = bill; + const { id, category, externalId, title } = bill; + + const { items: userVote } = useAxios_NOT_Authenticated_GET(`/user_votes/${bill.id}`); const handleGoToSingleBill = useCallback(() => { router.visit(ROUTES.bill(bill.id)); @@ -46,18 +48,21 @@ const BillsListItem: React.FC = ({ bill, userVote, isLastItem, inView }) {category && {titleize(category)}}
-
-
{`Bill ${externalId}`}
-
{title}
-
+ +
+
{`Bill ${externalId}`}
+
{title}
+
+
+
{locale && userVote && !IS_MOBILE_PHONE && (
- + }> - +
)}
diff --git a/app/frontend/components/bill/charts/BillChartsContainer.tsx b/app/frontend/components/bill/charts/BillChartsContainer.tsx index 95aa0cd7..6e2aa5f0 100644 --- a/app/frontend/components/bill/charts/BillChartsContainer.tsx +++ b/app/frontend/components/bill/charts/BillChartsContainer.tsx @@ -1,17 +1,17 @@ /** @format */ +import FullScreenLoading from "app/frontend/components/dialogs/FullScreenLoading"; +import SuspenseFullScreen from "app/frontend/components/dialogs/SuspenseFullScreen"; +import { useAxiosGet } from "app/frontend/hooks/useAxios"; import { isCongressLocale, isEmptyObject, logDev } from "app/frontend/sway_utils"; -import { lazy, useRef, useState } from "react"; +import { lazy, useCallback, useRef, useState } from "react"; +import { Button } from "react-bootstrap"; import { sway } from "sway"; import { useOpenCloseElement } from "../../../hooks/elements/useOpenCloseElement"; import { isEmptyScore } from "../../../sway_utils/charts"; import { BillChartFilters } from "./constants"; import DistrictVotesChart from "./DistrictVotesChart"; import TotalVotesChart from "./TotalVotesChart"; -import { useAxiosGet } from "app/frontend/hooks/useAxios"; -import FullScreenLoading from "app/frontend/components/dialogs/FullScreenLoading"; -import { Button } from "react-bootstrap"; -import SuspenseFullScreen from "app/frontend/components/dialogs/SuspenseFullScreen"; const DialogWrapper = lazy(() => import("../../dialogs/DialogWrapper")); @@ -42,14 +42,18 @@ const BillChartsContainer: React.FC = ({ bill, locale, filter }) => { const { items: billScore } = useAxiosGet(`/bill_scores/${bill.id}`); - const handleSetSelected = (index: number) => { - setOpen(true); - setSelected(index); - }; - const handleClose = () => { + const handleSetSelected = useCallback( + (index: number) => { + setOpen(true); + setSelected(index); + }, + [setOpen], + ); + + const handleClose = useCallback(() => { setOpen(false); setSelected(-1); - }; + }, [setOpen]); const components = [ { diff --git a/app/frontend/components/bill/charts/BillMobileChartsContainer.tsx b/app/frontend/components/bill/charts/BillMobileChartsContainer.tsx index fb48b9fb..575a6e6c 100644 --- a/app/frontend/components/bill/charts/BillMobileChartsContainer.tsx +++ b/app/frontend/components/bill/charts/BillMobileChartsContainer.tsx @@ -143,10 +143,7 @@ const BillMobileChartsContainer: React.FC = ({ bill, filter }) => { {charts.map((item: IChartChoice, index: number) => { const isSelected = index === selected; return ( -
+
-
-
- ) : ( - - )} -
-
- -
- - }> - {showUploadModal && ( - - )} - - - ); -}; - -export default BillCreatorOrganization; diff --git a/app/frontend/components/bill/creator/BillOfTheWeekCreator.tsx b/app/frontend/components/bill/creator/BillOfTheWeekCreator.tsx new file mode 100644 index 00000000..8fbb504f --- /dev/null +++ b/app/frontend/components/bill/creator/BillOfTheWeekCreator.tsx @@ -0,0 +1,182 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +/** @format */ +import { ROUTES } from "app/frontend/sway_constants"; +import { logDev, REACT_SELECT_STYLES } from "app/frontend/sway_utils"; +import { lazy, Suspense, useCallback, useMemo, useState } from "react"; +import { Button, ButtonGroup, Form, Nav, ProgressBar, Tab } from "react-bootstrap"; +import Select, { SingleValue } from "react-select"; +import { ISelectOption, sway } from "sway"; + +import { router } from "@inertiajs/react"; +import { useLocale } from "app/frontend/hooks/useLocales"; + +import FullScreenLoading from "app/frontend/components/dialogs/FullScreenLoading"; +import SwayLogo from "app/frontend/components/SwayLogo"; +import LocaleSelector from "app/frontend/components/user/LocaleSelector"; + +import BillCreatorAccordions from "app/frontend/components/admin/creator/BillCreatorAccordions"; +import { ETab } from "app/frontend/components/bill/creator/constants"; +import { TempBillStorage } from "app/frontend/components/bill/creator/TempBillStorage"; +import { useSearchParams } from "app/frontend/hooks/useSearchParams"; + +const BillSchedule = lazy(() => import("app/frontend/components/bill/creator/BillSchedule")); + +interface IProps { + bills: sway.IBill[]; + bill: sway.IBill & { organizations: sway.IOrganization[] }; + legislators: sway.ILegislator[]; + legislatorVotes: sway.ILegislatorVote[]; + locale: sway.ISwayLocale; + organizations: sway.IOrganization[]; + user: sway.IUser; + tabKey?: ETab; +} + +const NEW_BILL_OPTION = { label: "New Bill", value: -1 }; + +const BillOfTheWeekCreator_: React.FC = ({ bills, bill, user, tabKey = ETab.Creator }) => { + const [locale] = useLocale(); + const params = useSearchParams(); + const { isAdmin } = user; + + const isLoading = useMemo(() => false, []); + const [isCreatorDirty, setCreatorDirty] = useState(false); + + const selectedBill = useMemo( + () => + bill.id + ? { + label: `${bill.externalId} - ${bill.title}`, + value: bill.id, + } + : NEW_BILL_OPTION, + [bill.externalId, bill.title, bill.id], + ); + + const options = useMemo( + () => + (bills ?? []) + .map((b) => ({ label: `${b.externalId} - ${b.title}`, value: b.id })) + .concat(bill.id ? [NEW_BILL_OPTION] : []), + [bills], + ); + + const handleChangeBill = useCallback((o: SingleValue, newParams?: Record) => { + if (!o) return; + + if (Number(o.value) > 0) { + router.visit(`${ROUTES.billOfTheWeekCreatorEdit(o.value)}?${params.toQs(newParams || {})}`, { + preserveScroll: true, + }); + } else { + router.visit(`${ROUTES.billOfTheWeekCreator}?${params.toQs(newParams || {})}`, { + preserveScroll: true, + }); + } + }, []); + + const handleChangeTab = useCallback( + (newTabKey: string | null) => { + if (!newTabKey) return; + + if (isCreatorDirty && newTabKey === ETab.Schedule) { + const isConfirmed = window.confirm( + "DANGER! Switching to the scheduler will remove all unsaved data from the Bill Creator. Only saved bills can be scheduled. Continue?", + ); + if (isConfirmed) { + params.add("tabKey", newTabKey); + } + } else { + params.add("tabKey", newTabKey); + } + }, + [isCreatorDirty], + ); + + if (!isAdmin || !locale) { + logDev("BillOfTheWeekCreator - no admin OR no locale - render null"); + return null; + } + + return ( +
+ {isLoading && } + +
+
+
+ Sway Locale + +
+
+ +
+
+ Previous Bill of the Day +
+ -
-
-
-
- -
- logDev("RESET FORMIK")} - > - {(formik) => { - return ( - <> - - -
-
-
- -
-
-
-
- -
-
-
-
-
-
Bill of the Week Preview
- - ({ - support: Support.For, - summary: p.summary, - organization: { - id: p.value, - name: p.label, - iconPath: p.iconPath, - }, - }) as sway.IOrganizationPosition, - ) - .concat( - formik.values.organizationsOppose.map( - (p) => - ({ - support: Support.Against, - summary: p.summary, - organization: { - id: p.value, - name: p.label, - iconPath: p.iconPath, - }, - }) as sway.IOrganizationPosition, - ), - )} - sponsor={ - legislators.find( - (l) => l.id === (formik.values.legislator?.value as number), - ) as sway.ILegislator - } - legislatorVotes={formik.values.supporters - .map( - (s) => - ({ - legislatorId: s.value, - support: Support.For, - billId: bill.id || -1, - }) as sway.ILegislatorVote, - ) - .concat( - formik.values.opposers.map( - (s) => - ({ - legislatorId: s.value, - support: Support.Against, - billId: bill.id || -1, - }) as sway.ILegislatorVote, - ), - ) - .concat( - formik.values.abstainers.map( - (s) => - ({ - legislatorId: s.value, - support: Support.Abstain, - billId: bill.id || -1, - }) as sway.ILegislatorVote, - ), - ) - .flat()} - /> - - ); - }} -
-
- ); -}; - -const legislatorToSelectOption = (legislator?: sway.ILegislator | null) => { - if (!legislator) return; - - return { - label: legislator.fullName, - value: legislator.id, - }; -}; - -const BillOfTheWeekCreator = BillOfTheWeekCreator_; -export default BillOfTheWeekCreator; diff --git a/app/frontend/pages/BillOfTheWeekCreatorPage.tsx b/app/frontend/pages/BillOfTheWeekCreatorPage.tsx new file mode 100644 index 00000000..17434387 --- /dev/null +++ b/app/frontend/pages/BillOfTheWeekCreatorPage.tsx @@ -0,0 +1,51 @@ +/** @format */ +import { sway } from "sway"; + +import { LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3"; + +import BillOfTheWeekCreator from "app/frontend/components/bill/creator/BillOfTheWeekCreator"; +import { useEffect } from "react"; +import { notify } from "app/frontend/sway_utils"; +import { ETab } from "app/frontend/components/bill/creator/constants"; + +interface IProps { + flash?: { + notice?: string; + alert?: string; + }; + bills: sway.IBill[]; + bill: sway.IBill & { organizations: sway.IOrganization[] }; + legislators: sway.ILegislator[]; + legislatorVotes: sway.ILegislatorVote[]; + locale: sway.ISwayLocale; + organizations: sway.IOrganization[]; + user: sway.IUser; + tabKey: ETab; +} + +const BillOfTheWeekCreatorPage_: React.FC = ({ flash, ...props }) => { + useEffect(() => { + if (flash?.notice) { + notify({ + level: "success", + title: flash.notice, + }); + } + if (flash?.alert) { + notify({ + level: "error", + title: flash.alert, + }); + } + }, [flash]); + + return ( + + + + ); +}; + +const BillOfTheWeekCreatorPage = BillOfTheWeekCreatorPage_; +export default BillOfTheWeekCreatorPage; diff --git a/app/frontend/pages/Bills.tsx b/app/frontend/pages/Bills.tsx index f5ba48c2..6344841b 100644 --- a/app/frontend/pages/Bills.tsx +++ b/app/frontend/pages/Bills.tsx @@ -3,18 +3,27 @@ import { useLocale } from "app/frontend/hooks/useLocales"; import { toFormattedLocaleName } from "app/frontend/sway_utils"; import { isEmpty } from "lodash"; -import { useMemo, useState } from "react"; -import { Fade } from "react-bootstrap"; +import { useEffect, useMemo, useState } from "react"; +import { Fade, ProgressBar } from "react-bootstrap"; +import { InView } from "react-intersection-observer"; import { sway } from "sway"; import BillsListCategoriesHeader from "../components/bill/BillsListCategoriesHeader"; import BillsListItem from "../components/bill/BillsListItem"; import LocaleSelector from "../components/user/LocaleSelector"; -import { InView } from "react-intersection-observer"; +import { router } from "@inertiajs/react"; -const Bills_: React.FC<{ bills: sway.IBill[] }> = ({ bills }) => { +interface IProps { + bills: sway.IBill[]; +} + +const Bills_: React.FC = ({ bills }) => { const [locale] = useLocale(); const [categories, setCategories] = useState([]); + useEffect(() => { + router.reload({ only: ["user_votes"] }); + }, []); + const render = useMemo(() => { if (!bills.length) { return ( @@ -29,12 +38,18 @@ const Bills_: React.FC<{ bills: sway.IBill[] }> = ({ bills }) => { } return bills.map((b, i) => ( - - {({ inView, ref }) => ( -
- -
- )} + + {({ inView, ref }) => + !inView ? ( +
+ +
+ ) : ( +
+ +
+ ) + }
)); }, [bills, categories, locale.name]); diff --git a/app/frontend/pages/Legislators.tsx b/app/frontend/pages/Legislators.tsx index a8e23324..c0fe1bec 100644 --- a/app/frontend/pages/Legislators.tsx +++ b/app/frontend/pages/Legislators.tsx @@ -4,7 +4,7 @@ import FullScreenLoading from "app/frontend/components/dialogs/FullScreenLoading import LegislatorCard from "app/frontend/components/legislator/LegislatorCard"; import LocaleAvatar from "app/frontend/components/locales/LocaleAvatar"; import LocaleSelector from "app/frontend/components/user/LocaleSelector"; -import { useAxiosPost } from "app/frontend/hooks/useAxios"; +import { useFetch } from "app/frontend/hooks/useFetch"; import { useLocale } from "app/frontend/hooks/useLocales"; import { toFormattedLocaleName } from "app/frontend/sway_utils"; import { isEmpty } from "lodash"; @@ -48,7 +48,7 @@ const Legislators_: React.FC = ({ legislators: representatives }) => { )); }, [reps]); - const { post } = useAxiosPost("/user_legislators"); + const post = useFetch("/user_legislators"); const handleFindLegislators = useCallback(() => { if (!locale?.id) return; diff --git a/app/frontend/pages/Notifications.tsx b/app/frontend/pages/Notifications.tsx index 34d2432f..dc7dd2bd 100644 --- a/app/frontend/pages/Notifications.tsx +++ b/app/frontend/pages/Notifications.tsx @@ -1,6 +1,6 @@ import { router } from "@inertiajs/react"; import { useAxiosPost } from "app/frontend/hooks/useAxios"; -import { handleError, logDev } from "app/frontend/sway_utils"; +import { handleError, logDev, notify } from "app/frontend/sway_utils"; import { useCallback, useEffect, useState } from "react"; import { Button } from "react-bootstrap"; import { sway } from "sway"; @@ -29,7 +29,14 @@ const Notifications: React.FC = ({ user: _user, subscriptions }) => { r.pushManager.getSubscription().then((s) => { if (s?.endpoint) { - testNotify({ endpoint: s.endpoint }).catch(console.error); + testNotify({ endpoint: s.endpoint }) + .then(() => { + notify({ + level: "success", + title: "Test notification sent. You should receive one soon...", + }); + }) + .catch(console.error); } }); }); @@ -109,7 +116,7 @@ const Notifications: React.FC = ({ user: _user, subscriptions }) => { .subscribe({ userVisibleOnly: true, applicationServerKey: ( - window as Window & typeof global & { VAPID_PUBLIC_KEY: string } + window as Window & typeof globalThis & { VAPID_PUBLIC_KEY: string } ).VAPID_PUBLIC_KEY, }) .then((sub) => { @@ -163,22 +170,22 @@ const Notifications: React.FC = ({ user: _user, subscriptions }) => { if (subscription?.subscribed) { return (
-
+
We'll stop sending you a push notification whenever a new Bill of the Week is released.
-
+
-

If you don't receive a notification make sure that notifications are permitted for this browser in your device settings.

+
); diff --git a/app/frontend/pages/Passkey.tsx b/app/frontend/pages/Passkey.tsx index b00e6e81..4e1cb919 100644 --- a/app/frontend/pages/Passkey.tsx +++ b/app/frontend/pages/Passkey.tsx @@ -2,32 +2,22 @@ import { useCallback, useMemo, useState } from "react"; import { sway } from "sway"; import { logDev, notify } from "app/frontend/sway_utils"; -import { PHONE_INPUT_TRANSFORMER, isValidPhoneNumber } from "app/frontend/sway_utils/phone"; -import { ErrorMessage, Field, FieldAttributes, Form, Formik, FormikProps } from "formik"; -import { Form as BootstrapForm, Button, Fade } from "react-bootstrap"; +import { PHONE_INPUT_TRANSFORMER } from "app/frontend/sway_utils/phone"; +import { Form as BootstrapForm, Button, Fade, Form } from "react-bootstrap"; -import { router } from "@inertiajs/react"; +import { router, usePage } from "@inertiajs/react"; import CenteredLoading from "app/frontend/components/dialogs/CenteredLoading"; import { useConfirmPhoneVerification } from "app/frontend/hooks/authentication/phone/useConfirmPhoneVerification"; import { useSendPhoneVerification } from "app/frontend/hooks/authentication/phone/useSendPhoneVerification"; import { useWebAuthnAuthentication } from "app/frontend/hooks/authentication/useWebAuthnAuthentication"; import { ROUTES } from "app/frontend/sway_constants"; import { AxiosError } from "axios"; -import * as yup from "yup"; interface ISigninValues { phone: string; code: string; } -const VALIDATION_SCHEMA = yup.object().shape({ - phone: yup - .string() - .required("Phone is required.") - .test("Is valid phone number", "Please enter a valid phone number.", (value) => isValidPhoneNumber(value)), - code: yup.string().max(6), -}); - const INITIAL_VALUES: ISigninValues = { phone: "", code: "", @@ -37,6 +27,9 @@ const INITIAL_VALUES: ISigninValues = { // https://medium.com/the-gnar-company/creating-passkey-authentication-in-a-rails-7-application-a0f03f9114c1 const Passkey: React.FC = () => { logDev("Passkey.tsx"); + const [phone, setPhone] = useState(INITIAL_VALUES.phone); + const [code, setCode] = useState(INITIAL_VALUES.code); + const errors = (usePage().props.errors ?? {}) as Record; const onAuthenticated = useCallback((user: sway.IUser) => { if (!user) { @@ -69,7 +62,9 @@ const Passkey: React.FC = () => { const [isConfirmingPhone, setConfirmingPhone] = useState(false); const handleSubmit = useCallback( - async ({ phone, code }: { phone: string; code?: string }) => { + async (e: React.FormEvent) => { + e.preventDefault(); + if (code && isConfirmingPhone) { confirmPhoneVerification(phone, code); } else { @@ -89,9 +84,9 @@ const Passkey: React.FC = () => { verifyAuthentication(phone, publicKey).catch(console.error); } }) - .catch((e: AxiosError) => { + .catch((error: AxiosError) => { console.warn(e); - if (e.response?.status === 422) { + if (error.response?.status === 422) { sendPhoneVerification(phone) .then((success) => { setConfirmingPhone(!!success); @@ -101,7 +96,15 @@ const Passkey: React.FC = () => { }); } }, - [isConfirmingPhone, confirmPhoneVerification, startAuthentication, verifyAuthentication, sendPhoneVerification], + [ + code, + isConfirmingPhone, + confirmPhoneVerification, + phone, + startAuthentication, + verifyAuthentication, + sendPhoneVerification, + ], ); const handleCancel = useCallback((e: React.MouseEvent) => { @@ -115,88 +118,76 @@ const Passkey: React.FC = () => {
- - {(_props: FormikProps) => ( -
-
-
 
-
- - - {({ field, form: { touched, errors } }: FieldAttributes) => ( - - - - )} - - - -
-
- -
-
 
-
- - - {({ field, form: { touched, errors } }: FieldAttributes) => ( - - - - )} - - - -
-
 
-
-
-
-
 
-
- - - -
-
- -
-
 
-
-
-
- -
+ /> + + +
-
- )} -
+
 
+
+ +
+
 
+
+ + + +
+
+ +
+
 
+
+
+
+ +
+
+
diff --git a/app/frontend/pages/Registration.tsx b/app/frontend/pages/Registration.tsx index 9340f4e2..d5a404d1 100644 --- a/app/frontend/pages/Registration.tsx +++ b/app/frontend/pages/Registration.tsx @@ -1,21 +1,19 @@ /** @format */ -import { Form, Formik } from "formik"; import { useCallback, useState } from "react"; -import { Badge, Button } from "react-bootstrap"; +import { Badge, Button, Form } from "react-bootstrap"; import { FiExternalLink, FiGithub } from "react-icons/fi"; import { sway } from "sway"; import { useLogout } from "../hooks/users/useLogout"; -import { useAxiosPost } from "app/frontend/hooks/useAxios"; +import FormContext from "app/frontend/components/contexts/FormContext"; import toast from "react-hot-toast"; +import { useInertiaForm } from "use-inertia-form"; import Dialog404 from "../components/dialogs/Dialog404"; import RegistrationFields from "../components/user/RegistrationFields"; -import { handleError, notify } from "../sway_utils"; -import { router } from "@inertiajs/react"; -import { ROUTES } from "app/frontend/sway_constants"; +import { notify } from "../sway_utils"; -const REGISTRATION_FIELDS: sway.IFormField[] = [ +const REGISTRATION_FIELDS: sway.IFormField[] = [ // { // name: "name", // component: "text", @@ -50,11 +48,14 @@ interface IProps { const Registration: React.FC = ({ user }) => { const logout = useLogout(); - const { post } = useAxiosPost("/sway_registration"); + const form = useInertiaForm(user.address); + const { post } = form; const [isLoading, setLoading] = useState(false); const handleSubmit = useCallback( - async (address: sway.IAddress) => { + (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); const toastId = notify({ level: "info", @@ -63,17 +64,12 @@ const Registration: React.FC = ({ user }) => { duration: 0, }); - post({ ...address }) - .then((result) => { - if (result && "user" in result) { - router.visit(ROUTES.legislators); - } - }) - .catch(handleError) - .finally(() => { + post("/sway_registration", { + onFinish: () => { toast.dismiss(toastId); setLoading(false); - }); + }, + }); }, [post], ); @@ -92,18 +88,13 @@ const Registration: React.FC = ({ user }) => { Sway requires additional information about you in order to match you with your representatives.

- -
+ +
-
+

diff --git a/app/frontend/polyfills/scroll_restoration_on_back.js b/app/frontend/polyfills/scroll_restoration_on_back.js new file mode 100644 index 00000000..6d065a88 --- /dev/null +++ b/app/frontend/polyfills/scroll_restoration_on_back.js @@ -0,0 +1,123 @@ +// Preserve scroll position when clicking the back button in Firefox +// Mimics chromoe behavior +// https://github.com/inertiajs/inertia/issues/1459 +// https://github.com/janpaul123/delayed-scroll-restoration-polyfill + +if (window.history.pushState) { + const SCROLL_RESTORATION_TIMEOUT_MS = 3000; + const TRY_TO_SCROLL_INTERVAL_MS = 50; + + const originalPushState = window.history.pushState; + const originalReplaceState = window.history.replaceState; + + // Store current scroll position in current state when navigating away. + window.history.pushState = function () { + const newStateOfCurrentPage = Object.assign({}, window.history.state, { + __scrollX: window.scrollX, + __scrollY: window.scrollY, + }); + originalReplaceState.call(window.history, newStateOfCurrentPage, ""); + + originalPushState.apply(window.history, arguments); + }; + + // Make sure we don't throw away scroll position when calling "replaceState". + window.history.replaceState = function (state, ...otherArgs) { + const newState = Object.assign( + {}, + { + __scrollX: window.history.state && window.history.state.__scrollX, + __scrollY: window.history.state && window.history.state.__scrollY, + }, + state, + ); + + originalReplaceState.apply(window.history, [newState].concat(otherArgs)); + }; + + let timeoutHandle = null; + let scrollBarWidth = null; + + // Try to scroll to the scrollTarget, but only if we can actually scroll + // there. Otherwise keep trying until we time out, then scroll as far as + // we can. + const tryToScrollTo = (scrollTarget) => { + // Stop any previous calls to "tryToScrollTo". + clearTimeout(timeoutHandle); + + const body = document.body; + const html = document.documentElement; + if (!scrollBarWidth) { + scrollBarWidth = getScrollbarWidth(); + } + + // From http://stackoverflow.com/a/1147768 + const documentWidth = Math.max( + body.scrollWidth, + body.offsetWidth, + html.clientWidth, + html.scrollWidth, + html.offsetWidth, + ); + const documentHeight = Math.max( + body.scrollHeight, + body.offsetHeight, + html.clientHeight, + html.scrollHeight, + html.offsetHeight, + ); + + if ( + (documentWidth + scrollBarWidth - window.innerWidth >= scrollTarget.x && + documentHeight + scrollBarWidth - window.innerHeight >= scrollTarget.y) || + Date.now() > scrollTarget.latestTimeToTry + ) { + window.scrollTo(scrollTarget.x, scrollTarget.y); + } else { + timeoutHandle = setTimeout(() => tryToScrollTo(scrollTarget), TRY_TO_SCROLL_INTERVAL_MS); + } + }; + + // Try scrolling to the previous scroll position on popstate + const onPopState = () => { + const state = window.history.state; + + if (state && Number.isFinite(state.__scrollX) && Number.isFinite(state.__scrollY)) { + setTimeout(() => + tryToScrollTo({ + x: state.__scrollX, + y: state.__scrollY, + latestTimeToTry: Date.now() + SCROLL_RESTORATION_TIMEOUT_MS, + }), + ); + } + }; + + // Calculating width of browser's scrollbar + function getScrollbarWidth() { + const outer = document.createElement("div"); + outer.style.visibility = "hidden"; + outer.style.width = "100px"; + outer.style.msOverflowStyle = "scrollbar"; + + document.body.appendChild(outer); + + const widthNoScroll = outer.offsetWidth; + // force scrollbars + outer.style.overflow = "scroll"; + + // add innerdiv + const inner = document.createElement("div"); + inner.style.width = "100%"; + outer.appendChild(inner); + + const widthWithScroll = inner.offsetWidth; + + // remove divs + outer.parentNode.removeChild(outer); + + return widthNoScroll - widthWithScroll; + } + + window.addEventListener("popstate", onPopState, true); +} diff --git a/app/frontend/styles/scss/main.scss b/app/frontend/styles/scss/main.scss index fcc3d623..83b03e96 100644 --- a/app/frontend/styles/scss/main.scss +++ b/app/frontend/styles/scss/main.scss @@ -10,10 +10,11 @@ $primary: map-get($sway-theme-colors, "primary"); $primaryDark: map-get($sway-theme-colors, "primaryDark"); $tertiary: map-get($sway-theme-colors, "tertiary"); -html, -body { - overflow-x: hidden; -} +// html, +// body { +// ! Cannot use - disables sticky header - https://stackoverflow.com/a/57711801/6410635 +// overflow-x: hidden; +// } body { font-family: "Exo 2", sans-serif !important; diff --git a/app/frontend/sway_constants/routes.ts b/app/frontend/sway_constants/routes.ts index 421a6696..0a78b412 100644 --- a/app/frontend/sway_constants/routes.ts +++ b/app/frontend/sway_constants/routes.ts @@ -1,3 +1,5 @@ +import { KeyOf } from "sway"; + export const ROUTES = { index: "/", signin: "/signin", @@ -24,3 +26,5 @@ export const ROUTES = { billOfTheWeekCreator: "/bills/new", billOfTheWeekCreatorEdit: (billId: string | number = ":billId") => `/bills/${billId}/edit`, }; + +export type RouteKey = KeyOf; diff --git a/app/frontend/sway_utils/bills.ts b/app/frontend/sway_utils/bills.ts new file mode 100644 index 00000000..7f24a8a2 --- /dev/null +++ b/app/frontend/sway_utils/bills.ts @@ -0,0 +1,3 @@ +import { sway } from "sway"; + +export const toSelectLabelFromBill = (bill: sway.IBill) => `${bill.externalId} - ${bill.title}`; diff --git a/app/frontend/sway_utils/datetimes.ts b/app/frontend/sway_utils/datetimes.ts index 8983afcd..200dbe2d 100644 --- a/app/frontend/sway_utils/datetimes.ts +++ b/app/frontend/sway_utils/datetimes.ts @@ -12,9 +12,6 @@ export const getDateFromString = (date?: string) => { } }; export const formatDate = (datetime: string): string => { + // return new Date(datetime).toLocaleDateString("en-US"); return new Date(datetime).toLocaleDateString("en-US"); }; - -export const formatDateTime = (datetime: string): string => { - return new Date(datetime).toLocaleString("en-US"); -}; diff --git a/app/frontend/sway_utils/emoji.ts b/app/frontend/sway_utils/emoji.ts index 1fbe0b2f..a5a104e3 100644 --- a/app/frontend/sway_utils/emoji.ts +++ b/app/frontend/sway_utils/emoji.ts @@ -1,26 +1,13 @@ -let EMOJIS = {}; - -const getEmojiFromName = async (name: string): Promise => { - const emojis = - EMOJIS || - (await fetch("./emojis.json") - .then((r) => r.json()) - .then((j) => { - EMOJIS = j; - return j; - }) - .catch((e) => { - console.error(e); - return {}; - })); +import EMOJIS from "./emojis.json"; +const getEmojiFromName = (name: string): string => { if (name.startsWith(":")) { - return (emojis as Record)[name.slice(1, -1)]; + return (EMOJIS as Record)[name.slice(1, -1)]; } - return (emojis as Record)[name]; + return (EMOJIS as Record)[name]; }; -export const withEmojis = async (string: string | undefined | null): Promise => { +export const withEmojis = (string: string | undefined | null): string => { const words = (string || "").split(" "); const render = [] as string[]; let i = 0; @@ -29,7 +16,7 @@ export const withEmojis = async (string: string | undefined | null): Promise { notify({ level: "error", title: `Error in Sway. ${message || DEFAULT_ERROR_MESSAGE}`, - message: message || DEFAULT_ERROR_MESSAGE, + // message: message || "", }); }; @@ -63,7 +63,7 @@ export const notify = ({ }: { id?: string; level: sway.TAlertLevel; - title: string | JSX.Element; + title: string | React.JSX.Element; message?: string; tada?: boolean; duration?: number; @@ -164,11 +164,6 @@ export const isEmptyObject = (obj: any) => { return true; }; -export const createNotificationDate = () => { - const date = new Date(); - return date.toISOString().split("T")[0]; -}; - export const isNumber = (value: any) => typeof value === "number" && isFinite(value); export const isNumeric = (string: string | null | undefined): boolean => { diff --git a/app/frontend/sway_utils/locales.ts b/app/frontend/sway_utils/locales.ts index 64279cb5..d5ddb9d8 100644 --- a/app/frontend/sway_utils/locales.ts +++ b/app/frontend/sway_utils/locales.ts @@ -1,7 +1,6 @@ - import { sway } from "sway"; import { titleize } from "."; -import { isEmpty } from "lodash"; +import { isEmpty, uniq } from "lodash"; import { CONGRESS_LOCALE_NAME } from "app/frontend/sway_constants"; export const SELECT_LOCALE_LABEL = "Select Locale"; @@ -9,9 +8,7 @@ export const LOCALE_NOT_LISTED_LABEL = "I don't see my Locale listed."; export const toLocaleNameItem = (string: string | undefined): string => { if (!string) { - console.error( - "toLocaleNameItem received falsey item string. Returning blank string in place.", - ); + console.error("toLocaleNameItem received falsey item string. Returning blank string in place."); return ""; } @@ -24,9 +21,7 @@ export const toLocaleNameItem = (string: string | undefined): string => { export const fromLocaleNameItem = (string: string | undefined): string => { if (string === "") return ""; if (!string) { - console.error( - "fromLocaleNameItem received falsey item string. Returning blank string in place.", - ); + console.error("fromLocaleNameItem received falsey item string. Returning blank string in place."); return ""; } @@ -60,13 +55,17 @@ export const toLocaleName = (address: sway.IAddress) => { }; export const toFormattedLocaleName = (name: string, includeCountry = true): string => { + if (isCongressLocale(name)) { + return "U.S. Congress"; + } if (!includeCountry) { - return splitLocaleName(name) - .map(fromLocaleNameItem) - .filter((_, i: number) => i < 2) - .join(", "); + return uniq( + splitLocaleName(name) + .map(fromLocaleNameItem) + .filter((_, i: number) => i < 2), + ).join(", "); } - return splitLocaleName(name).map(fromLocaleNameItem).join(", "); + return uniq(splitLocaleName(name).map(fromLocaleNameItem)).join(", "); }; export const findNotCongressLocale = (locales: sway.ISwayLocale[]): sway.ISwayLocale => { @@ -91,10 +90,7 @@ export const isNotUsersLocale = (user: sway.IUser | undefined, locale: sway.ISwa return locale.name !== CONGRESS_LOCALE_NAME && !localeNames_.includes(locale.name); }; -const getLocaleByEquality = ( - locale: sway.ISwayLocale | string | undefined, - l: sway.ISwayLocale, -) => { +const getLocaleByEquality = (locale: sway.ISwayLocale | string | undefined, l: sway.ISwayLocale) => { if (!locale) return false; if (typeof locale === "string") { diff --git a/app/frontend/sway_utils/sentry.ts b/app/frontend/sway_utils/sentry.ts index 6ab10836..4fbd9c93 100644 --- a/app/frontend/sway_utils/sentry.ts +++ b/app/frontend/sway_utils/sentry.ts @@ -1,4 +1,4 @@ -const Sentry = import("@sentry/react"); +import * as Sentry from "@sentry/react"; import { logDev } from "app/frontend/sway_utils"; import { noop } from "lodash"; diff --git a/app/frontend/sway_utils/storage.ts b/app/frontend/sway_utils/storage.ts index 0dd4262e..f62951c0 100644 --- a/app/frontend/sway_utils/storage.ts +++ b/app/frontend/sway_utils/storage.ts @@ -5,11 +5,14 @@ const IS_COOKIES_ENABLED = !!window.document && "cookie" in window.document; export const SWAY_STORAGE = { Local: { User: { - EmailConfirmed: "@sway/user/EmailConfirmed", - FirebaseCaching: "@sway/local/user/FirebaseCaching", InvitedBy: "@sway/local/user/InvitedBy", Registered: "@sway/local/user/Registered", - SignedIn: "@sway/user/SignedIn", + Phone: "@sway/user/phone", + }, + BillOfTheWeek: { + Bill: "@sway/botw/temp/bill", + Organizations: "@sway/botw/temp/organizations", + LegislatorVotes: "@sway/botw/temp/legislator_votes", }, }, Session: { @@ -29,11 +32,14 @@ const setCookie = (key: string, value: string) => { const getCookie = (key: string): string | null => { if (!IS_COOKIES_ENABLED) return null; - const cookies = window.document.cookie.split(";").reduce((sum, cookie) => { - const [cookieKey, cookieValue] = cookie.trim().split("="); - sum[cookieKey.trim() as string] = cookieValue.trim(); - return sum; - }, {} as Record); + const cookies = window.document.cookie.split(";").reduce( + (sum, cookie) => { + const [cookieKey, cookieValue] = cookie.trim().split("="); + sum[cookieKey.trim() as string] = cookieValue.trim(); + return sum; + }, + {} as Record, + ); return get(cookies, key).trim(); }; @@ -142,7 +148,6 @@ export const clear = () => { } }; - const prependSlash = (s: string) => { if (!s) return ""; if (s.startsWith("/")) { @@ -163,8 +168,6 @@ export const getStoragePath = ( if (path.includes(localeName)) { return p.split("?")[0]; } else { - return prependSlash( - `${prependSlash(localeName)}${prependSlash(directory)}${p.split("?")[0]}`, - ); + return prependSlash(`${prependSlash(localeName)}${prependSlash(directory)}${p.split("?")[0]}`); } }; diff --git a/app/frontend/sway_utils/styles.ts b/app/frontend/sway_utils/styles.ts index 3304c02a..ef0a84b5 100644 --- a/app/frontend/sway_utils/styles.ts +++ b/app/frontend/sway_utils/styles.ts @@ -67,6 +67,7 @@ export const REACT_SELECT_STYLES = { ...provided, cursor: "pointer", }), + menuPortal: (provided: any) => ({ ...provided, zIndex: 10000 }), menu: (provided: any) => ({ ...provided, zIndex: 10000, diff --git a/app/models/address.rb b/app/models/address.rb index f5fac724..54480a94 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -6,18 +6,22 @@ # Table name: addresses # # id :integer not null, primary key -# street :string not null -# street2 :string -# street3 :string # city :string not null -# region_code :string not null -# postal_code :string not null # country :string default("US"), not null # latitude :float # longitude :float +# postal_code :string not null +# region_code :string not null +# street :string not null +# street2 :string +# street3 :string # created_at :datetime not null # updated_at :datetime not null # +# Indexes +# +# index_addresses_on_latitude_and_longitude (latitude,longitude) +# class Address < ApplicationRecord extend T::Sig diff --git a/app/models/bill.rb b/app/models/bill.rb index 834b3075..c3b2b07e 100644 --- a/app/models/bill.rb +++ b/app/models/bill.rb @@ -5,26 +5,39 @@ # # Table name: bills # -# id :integer not null, primary key -# external_id :string not null -# external_version :string -# title :string not null -# link :string -# chamber :string not null -# introduced_date_time_utc :datetime not null -# house_vote_date_time_utc :datetime -# senate_vote_date_time_utc :datetime -# level :string not null -# category :string not null -# summary :text -# legislator_id :integer not null -# sway_locale_id :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# status :string -# active :boolean -# audio_bucket_path :string -# audio_by_line :string +# id :integer not null, primary key +# active :boolean +# audio_bucket_path :string +# audio_by_line :string +# category :string not null +# chamber :string not null +# external_version :string +# house_vote_date_time_utc :datetime +# introduced_date_time_utc :datetime not null +# level :string not null +# link :string +# scheduled_release_date_utc :date +# senate_vote_date_time_utc :datetime +# status :string +# summary :text +# title :string not null +# created_at :datetime not null +# updated_at :datetime not null +# external_id :string not null +# legislator_id :integer not null +# sway_locale_id :integer not null +# +# Indexes +# +# index_bills_on_external_id_and_sway_locale_id (external_id,sway_locale_id) UNIQUE +# index_bills_on_legislator_id (legislator_id) +# index_bills_on_scheduled_release_date_utc_and_sway_locale_id (scheduled_release_date_utc,sway_locale_id) UNIQUE +# index_bills_on_sway_locale_id (sway_locale_id) +# +# Foreign Keys +# +# legislator_id (legislator_id => legislators.id) +# sway_locale_id (sway_locale_id => sway_locales.id) # class Bill < ApplicationRecord @@ -40,12 +53,21 @@ class Bill < ApplicationRecord has_many :legislator_votes, inverse_of: :bill, dependent: :destroy has_many :organization_bill_positions, inverse_of: :bill, dependent: :destroy - before_save :downcase_status, :determine_level, :determine_chamber - after_create :send_notifications + before_validation :downcase_status, :determine_level, :determine_chamber + after_update :send_notifications_on_update + validates :external_id, :category, :chamber, :introduced_date_time_utc, :level, :link, :status, :summary, :title, :sway_locale_id, :legislator_id, presence: { + # A String :message value can optionally contain any/all of + # %{value}, %{attribute}, and %{model} which will be dynamically replaced when validation fails. + message: "%{attribute} can't be blank" + } validates :external_id, uniqueness: {scope: :sway_locale_id, allow_nil: true} - scope :of_the_week, -> { last } + sig { params(sway_locale: SwayLocale).returns(Bill) } + def self.of_the_week(sway_locale:) + b = Bill.where(scheduled_release_date_utc: Time.zone.today, sway_locale:).first + b.presence || Bill.where(sway_locale:).order(created_at: :asc).limit(1).first + end class Status PASSED = "passed" @@ -79,6 +101,11 @@ def vote votes.last end + sig { returns(T::Array[Organization]) } + def organizations + organization_bill_positions.map(&:organization) + end + # Render a single bill from a controller def render(current_user) { @@ -108,6 +135,8 @@ def to_builder b.status status b.active active + b.scheduled_release_date_utc scheduled_release_date_utc + b.audio_bucket_path audio_bucket_path b.audio_by_line audio_by_line @@ -180,10 +209,19 @@ def downcase_status # after create sig { void } - def send_notifications - SwayPushNotificationService.new( - title: "New Bill of the Week", - body: "#{title} in #{sway_locale.human_name}" - ).send_push_notification + def send_notifications_on_update + Rails.logger.info("Bill.send_notifications_on_update - New Release Date - #{scheduled_release_date_utc} - WAS - #{attribute_before_last_save("scheduled_release_date_utc")}") + if updated_scheduled_release_date_utc? + SwayPushNotificationService.new( + title: "New Bill of the Week", + body: "#{title} in #{sway_locale.human_name}" + ).send_push_notification + end + rescue Exception => e # rubocop:disable Lint/RescueException + Rails.logger.error(e) + end + + def updated_scheduled_release_date_utc? + scheduled_release_date_utc == Time.zone.today && attribute_before_last_save("scheduled_release_date_utc") != Time.zone.today end end diff --git a/app/models/bill_cosponsor.rb b/app/models/bill_cosponsor.rb index 382b2f22..65356231 100644 --- a/app/models/bill_cosponsor.rb +++ b/app/models/bill_cosponsor.rb @@ -6,10 +6,20 @@ # Table name: bill_cosponsors # # id :integer not null, primary key -# legislator_id :integer not null -# bill_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# bill_id :integer not null +# legislator_id :integer not null +# +# Indexes +# +# index_bill_cosponsors_on_bill_id (bill_id) +# index_bill_cosponsors_on_legislator_id (legislator_id) +# +# Foreign Keys +# +# bill_id (bill_id => bills.id) +# legislator_id (legislator_id => legislators.id) # class BillCosponsor < ApplicationRecord belongs_to :legislator diff --git a/app/models/bill_score.rb b/app/models/bill_score.rb index 9bfe69fd..6dac0f50 100644 --- a/app/models/bill_score.rb +++ b/app/models/bill_score.rb @@ -6,11 +6,19 @@ # Table name: bill_scores # # id :integer not null, primary key -# bill_id :integer not null -# for :integer default(0), not null # against :integer default(0), not null +# for :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null +# bill_id :integer not null +# +# Indexes +# +# index_bill_scores_on_bill_id (bill_id) +# +# Foreign Keys +# +# bill_id (bill_id => bills.id) # class BillScore < ApplicationRecord diff --git a/app/models/bill_score_district.rb b/app/models/bill_score_district.rb index b08be8ac..30bf9ed9 100644 --- a/app/models/bill_score_district.rb +++ b/app/models/bill_score_district.rb @@ -6,12 +6,22 @@ # Table name: bill_score_districts # # id :integer not null, primary key -# bill_score_id :integer not null -# district_id :integer not null -# for :integer default(0), not null # against :integer default(0), not null +# for :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null +# bill_score_id :integer not null +# district_id :integer not null +# +# Indexes +# +# index_bill_score_districts_on_bill_score_id (bill_score_id) +# index_bill_score_districts_on_district_id (district_id) +# +# Foreign Keys +# +# bill_score_id (bill_score_id => bill_scores.id) +# district_id (district_id => districts.id) # class BillScoreDistrict < ApplicationRecord extend T::Sig diff --git a/app/models/district.rb b/app/models/district.rb index 837adae8..49f66a0f 100644 --- a/app/models/district.rb +++ b/app/models/district.rb @@ -7,9 +7,18 @@ # # id :integer not null, primary key # name :string not null -# sway_locale_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# sway_locale_id :integer not null +# +# Indexes +# +# index_districts_on_name_and_sway_locale_id (name,sway_locale_id) UNIQUE +# index_districts_on_sway_locale_id (sway_locale_id) +# +# Foreign Keys +# +# sway_locale_id (sway_locale_id => sway_locales.id) # class District < ApplicationRecord extend T::Sig diff --git a/app/models/invite.rb b/app/models/invite.rb index 597bbe8a..b3014c2a 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -6,10 +6,21 @@ # Table name: invites # # id :integer not null, primary key -# inviter_id :integer not null -# invitee_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# invitee_id :integer not null +# inviter_id :integer not null +# +# Indexes +# +# index_invites_on_invitee_id (invitee_id) +# index_invites_on_inviter_id (inviter_id) +# index_invites_on_inviter_id_and_inviter_id (inviter_id) UNIQUE +# +# Foreign Keys +# +# invitee_id (invitee_id => users.id) +# inviter_id (inviter_id => users.id) # class Invite < ApplicationRecord extend T::Sig diff --git a/app/models/legislator.rb b/app/models/legislator.rb index 79237764..de698b87 100644 --- a/app/models/legislator.rb +++ b/app/models/legislator.rb @@ -6,22 +6,32 @@ # Table name: legislators # # id :integer not null, primary key -# external_id :string not null # active :boolean not null -# link :string # email :string -# title :string +# fax :string # first_name :string not null # last_name :string not null -# phone :string -# fax :string +# link :string # party :string not null +# phone :string # photo_url :string -# address_id :integer not null -# district_id :integer not null +# title :string +# twitter :string # created_at :datetime not null # updated_at :datetime not null -# twitter :string +# address_id :integer not null +# district_id :integer not null +# external_id :string not null +# +# Indexes +# +# index_legislators_on_address_id (address_id) +# index_legislators_on_district_id (district_id) +# +# Foreign Keys +# +# address_id (address_id => addresses.id) +# district_id (district_id => districts.id) # class Legislator < ApplicationRecord extend T::Sig @@ -78,6 +88,18 @@ def legislator_district_score T.cast(super, LegislatorDistrictScore) end + # The year the Legislator was elected + sig { returns(Numeric) } + def election_year + if congress? + (created_at.year % 2 > 0) ? created_at.year - 1 : created_at.year + else + external_id.split("-").last.to_i + end + end + + delegate :congress?, to: :sway_locale + sig { returns(Jbuilder) } def to_builder Jbuilder.new do |l| diff --git a/app/models/legislator_district_score.rb b/app/models/legislator_district_score.rb index d159d2dd..178752d9 100644 --- a/app/models/legislator_district_score.rb +++ b/app/models/legislator_district_score.rb @@ -5,14 +5,24 @@ # Table name: legislator_district_scores # # id :integer not null, primary key -# district_id :integer not null -# legislator_id :integer not null # count_agreed :integer default(0), not null # count_disagreed :integer default(0), not null -# count_no_legislator_vote :integer default(0), not null # count_legislator_abstained :integer default(0), not null +# count_no_legislator_vote :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null +# district_id :integer not null +# legislator_id :integer not null +# +# Indexes +# +# index_legislator_district_scores_on_district_id (district_id) +# index_legislator_district_scores_on_legislator_id (legislator_id) +# +# Foreign Keys +# +# district_id (district_id => districts.id) +# legislator_id (legislator_id => legislators.id) # # typed: true diff --git a/app/models/legislator_vote.rb b/app/models/legislator_vote.rb index c53192a9..261f27b1 100644 --- a/app/models/legislator_vote.rb +++ b/app/models/legislator_vote.rb @@ -6,11 +6,21 @@ # Table name: legislator_votes # # id :integer not null, primary key -# legislator_id :integer not null -# bill_id :integer not null # support :string not null # created_at :datetime not null # updated_at :datetime not null +# bill_id :integer not null +# legislator_id :integer not null +# +# Indexes +# +# index_legislator_votes_on_bill_id (bill_id) +# index_legislator_votes_on_legislator_id (legislator_id) +# +# Foreign Keys +# +# bill_id (bill_id => bills.id) +# legislator_id (legislator_id => legislators.id) # class LegislatorVote < ApplicationRecord extend T::Sig @@ -20,6 +30,8 @@ class LegislatorVote < ApplicationRecord after_initialize :transform_support_to_for_against_abstain, :upcase_support + validates :support, :bill_id, :legislator_id, presence: {message: "%{attribute} can't be blank"} + validates :support, inclusion: {in: %w[FOR AGAINST ABSTAIN]} sig { returns(Bill) } diff --git a/app/models/organization.rb b/app/models/organization.rb index 4d9ff86a..b55a7c60 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -6,14 +6,24 @@ # Table name: organizations # # id :integer not null, primary key -# sway_locale_id :integer not null -# name :string not null # icon_path :string +# name :string not null # created_at :datetime not null # updated_at :datetime not null +# sway_locale_id :integer not null +# +# Indexes +# +# index_organizations_on_name_and_sway_locale_id (name,sway_locale_id) UNIQUE +# index_organizations_on_sway_locale_id (sway_locale_id) +# +# Foreign Keys +# +# sway_locale_id (sway_locale_id => sway_locales.id) # class Organization < ApplicationRecord extend T::Sig + include SwayGoogleCloudStorage belongs_to :sway_locale @@ -22,6 +32,17 @@ class Organization < ApplicationRecord validates :name, uniqueness: {scope: :sway_locale_id, allow_nil: true} + def positions + organization_bill_positions + end + + def remove_icon(current_icon_path) + return if current_icon_path.blank? + return unless icon_path != current_icon_path + + delete_file(bucket_name: SwayGoogleCloudStorage::BUCKETS[:ASSETS], file_name: current_icon_path) + end + sig { params(with_positions: T::Boolean).returns(Jbuilder) } def to_builder(with_positions:) Jbuilder.new do |o| diff --git a/app/models/organization_bill_position.rb b/app/models/organization_bill_position.rb index d3bb5042..e2ba7b21 100644 --- a/app/models/organization_bill_position.rb +++ b/app/models/organization_bill_position.rb @@ -6,12 +6,23 @@ # Table name: organization_bill_positions # # id :integer not null, primary key -# bill_id :integer not null -# organization_id :integer not null -# support :string not null # summary :text not null +# support :string not null # created_at :datetime not null # updated_at :datetime not null +# bill_id :integer not null +# organization_id :integer not null +# +# Indexes +# +# idx_on_bill_id_organization_id_f380340a40 (bill_id,organization_id) UNIQUE +# index_organization_bill_positions_on_bill_id (bill_id) +# index_organization_bill_positions_on_organization_id (organization_id) +# +# Foreign Keys +# +# bill_id (bill_id => bills.id) +# organization_id (organization_id => organizations.id) # class OrganizationBillPosition < ApplicationRecord extend T::Sig @@ -23,6 +34,8 @@ class OrganizationBillPosition < ApplicationRecord validates :bill_id, uniqueness: {scope: :organization_id, allow_nil: true} + validates :support, :summary, :organization, :bill, presence: {message: "can't be blank"} + sig { returns(Bill) } def bill T.cast(super, Bill) diff --git a/app/models/passkey.rb b/app/models/passkey.rb index c417ae04..492e0dd7 100644 --- a/app/models/passkey.rb +++ b/app/models/passkey.rb @@ -6,14 +6,24 @@ # Table name: passkeys # # id :integer not null, primary key -# user_id :integer not null # label :string not null -# external_id :string +# last_used_at :datetime # public_key :string # sign_count :integer -# last_used_at :datetime # created_at :datetime not null # updated_at :datetime not null +# external_id :string +# user_id :integer not null +# +# Indexes +# +# index_passkeys_on_external_id (external_id) UNIQUE +# index_passkeys_on_public_key (public_key) UNIQUE +# index_passkeys_on_user_id (user_id) +# +# Foreign Keys +# +# user_id (user_id => users.id) # class Passkey < ApplicationRecord belongs_to :user diff --git a/app/models/push_notification_subscription.rb b/app/models/push_notification_subscription.rb index f7b0dc61..e901c614 100644 --- a/app/models/push_notification_subscription.rb +++ b/app/models/push_notification_subscription.rb @@ -6,13 +6,21 @@ # Table name: push_notification_subscriptions # # id :integer not null, primary key -# user_id :integer not null +# auth :string # endpoint :string # p256dh :string -# auth :string # subscribed :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null +# user_id :integer not null +# +# Indexes +# +# index_push_notification_subscriptions_on_user_id (user_id) +# +# Foreign Keys +# +# user_id (user_id => users.id) # class PushNotificationSubscription < ApplicationRecord extend T::Sig @@ -31,6 +39,8 @@ def send_web_push_notification(message) urgency: "high", # optional, it can be very-low, low, normal, high, defaults to normal vapid: ) + rescue Exception => e # rubocop:disable Lint/RescueException + Rails.logger.error(e) end private diff --git a/app/models/sway_locale.rb b/app/models/sway_locale.rb index 40bb98d9..911cbbb8 100644 --- a/app/models/sway_locale.rb +++ b/app/models/sway_locale.rb @@ -7,13 +7,18 @@ # # id :integer not null, primary key # city :string not null -# state :string not null # country :string default("United States"), not null -# created_at :datetime not null -# updated_at :datetime not null # current_session_start_date :date -# time_zone :string # icon_path :string +# latest_election_year :integer default(2024), not null +# state :string not null +# time_zone :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_sway_locales_on_city_and_state_and_country (city,state,country) UNIQUE # class SwayLocale < ApplicationRecord @@ -68,11 +73,6 @@ def region? RegionUtil.from_region_name_to_region_code(city_name).present? end - sig { returns(T::Array[District]) } - def districts - T.cast(super, T::Array[District]).uniq(&:name) - end - sig { returns(T::Array[Legislator]) } def legislators districts.flat_map(&:legislators) diff --git a/app/models/user.rb b/app/models/user.rb index dddc9bd2..d5c88356 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,26 +6,33 @@ # Table name: users # # id :integer not null, primary key +# current_sign_in_at :datetime +# current_sign_in_ip :string # email :string +# is_admin :boolean default(FALSE) # is_email_verified :boolean -# phone :string # is_phone_verified :boolean -# is_registration_complete :boolean # is_registered_to_vote :boolean -# is_admin :boolean default(FALSE) -# webauthn_id :string -# sign_in_count :integer default(0), not null -# current_sign_in_at :datetime +# is_registration_complete :boolean # last_sign_in_at :datetime -# current_sign_in_ip :string # last_sign_in_ip :string +# phone :string +# sign_in_count :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null +# webauthn_id :string +# +# Indexes +# +# index_users_on_email (email) UNIQUE +# index_users_on_phone (phone) UNIQUE +# index_users_on_webauthn_id (webauthn_id) UNIQUE # class User < ApplicationRecord extend T::Sig CREDENTIAL_MIN_AMOUNT = 1 + ADMIN_PHONES = ENV["ADMIN_PHONES"]&.split(",") || [] attr_accessor :webauthn_id @@ -39,6 +46,7 @@ class User < ApplicationRecord has_many :passkeys, dependent: :destroy has_many :user_legislators, dependent: :destroy + has_many :user_votes, dependent: :destroy validates :phone, presence: true, uniqueness: true, length: {minimum: 10, maximum: 10} validates :email, uniqueness: {allow_nil: true} @@ -78,7 +86,7 @@ def default_sway_locale sig { params(sway_locale: SwayLocale).returns(T::Array[UserLegislator]) } def user_legislators_by_locale(sway_locale) - user_legislators.select { |ul| sway_locale.eql?(ul.legislator.district.sway_locale) } + user_legislators.where(active: true).select { |ul| sway_locale.eql?(ul.legislator.district.sway_locale) } end sig { params(sway_locale: SwayLocale).returns(T::Array[Legislator]) } @@ -106,7 +114,7 @@ def to_builder sig { returns(T::Boolean) } def is_admin? - (ENV["ADMIN_PHONES"]&.split(",") || []).include?(phone) + ADMIN_PHONES.include?(phone) end sig { returns(T::Boolean) } diff --git a/app/models/user_address.rb b/app/models/user_address.rb index b39102fe..e83eb01a 100644 --- a/app/models/user_address.rb +++ b/app/models/user_address.rb @@ -5,10 +5,20 @@ # Table name: user_addresses # # id :integer not null, primary key -# address_id :integer not null -# user_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# address_id :integer not null +# user_id :integer not null +# +# Indexes +# +# index_user_addresses_on_address_id (address_id) +# index_user_addresses_on_user_id (user_id) +# +# Foreign Keys +# +# address_id (address_id => addresses.id) +# user_id (user_id => users.id) # class UserAddress < ApplicationRecord belongs_to :address diff --git a/app/models/user_district.rb b/app/models/user_district.rb index 3c4a13f5..18b63e82 100644 --- a/app/models/user_district.rb +++ b/app/models/user_district.rb @@ -6,10 +6,20 @@ # Table name: user_districts # # id :integer not null, primary key -# district_id :integer not null -# user_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# district_id :integer not null +# user_id :integer not null +# +# Indexes +# +# index_user_districts_on_district_id (district_id) +# index_user_districts_on_user_id (user_id) +# +# Foreign Keys +# +# district_id (district_id => districts.id) +# user_id (user_id => users.id) # class UserDistrict < ApplicationRecord belongs_to :district diff --git a/app/models/user_inviter.rb b/app/models/user_inviter.rb index 7084e267..92fef419 100644 --- a/app/models/user_inviter.rb +++ b/app/models/user_inviter.rb @@ -6,10 +6,19 @@ # Table name: user_inviters # # id :integer not null, primary key -# user_id :integer not null # invite_uuid :string not null # created_at :datetime not null # updated_at :datetime not null +# user_id :integer not null +# +# Indexes +# +# index_user_inviters_on_invite_uuid (invite_uuid) UNIQUE +# index_user_inviters_on_user_id (user_id) +# +# Foreign Keys +# +# user_id (user_id => users.id) # class UserInviter < ApplicationRecord extend T::Sig diff --git a/app/models/user_legislator.rb b/app/models/user_legislator.rb index 2ec69a33..baf0d59f 100644 --- a/app/models/user_legislator.rb +++ b/app/models/user_legislator.rb @@ -6,10 +6,21 @@ # Table name: user_legislators # # id :integer not null, primary key -# legislator_id :integer not null -# user_id :integer not null +# active :boolean default(TRUE), not null # created_at :datetime not null # updated_at :datetime not null +# legislator_id :integer not null +# user_id :integer not null +# +# Indexes +# +# index_user_legislators_on_legislator_id (legislator_id) +# index_user_legislators_on_user_id (user_id) +# +# Foreign Keys +# +# legislator_id (legislator_id => legislators.id) +# user_id (user_id => users.id) # class UserLegislator < ApplicationRecord extend T::Sig diff --git a/app/models/user_legislator_score.rb b/app/models/user_legislator_score.rb index a5f82255..3aca0c08 100644 --- a/app/models/user_legislator_score.rb +++ b/app/models/user_legislator_score.rb @@ -6,13 +6,21 @@ # Table name: user_legislator_scores # # id :integer not null, primary key -# user_legislator_id :integer not null # count_agreed :integer default(0), not null # count_disagreed :integer default(0), not null -# count_no_legislator_vote :integer default(0), not null # count_legislator_abstained :integer default(0), not null +# count_no_legislator_vote :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null +# user_legislator_id :integer not null +# +# Indexes +# +# index_user_legislator_scores_on_user_legislator_id (user_legislator_id) +# +# Foreign Keys +# +# user_legislator_id (user_legislator_id => user_legislators.id) # class UserLegislatorScore < ApplicationRecord extend T::Sig diff --git a/app/models/user_vote.rb b/app/models/user_vote.rb index 34c0b076..b9e2269a 100644 --- a/app/models/user_vote.rb +++ b/app/models/user_vote.rb @@ -6,6 +6,23 @@ # Table name: user_votes # # id :integer not null, primary key +# support :string not null +# created_at :datetime not null +# updated_at :datetime not null +# bill_id :integer not null +# user_id :integer not null +# +# Indexes +# +# index_user_votes_on_bill_id (bill_id) +# index_user_votes_on_user_id (user_id) +# +# Foreign Keys +# +# bill_id (bill_id => bills.id) +# user_id (user_id => users.id) +# +# id :integer not null, primary key # user_id :integer not null # bill_id :integer not null # support :string not null @@ -19,9 +36,10 @@ class UserVote < ApplicationRecord belongs_to :bill after_initialize :upcase_support - after_commit :update_scores + after_create_commit :update_scores validates :support, inclusion: {in: %w[FOR AGAINST]} + validates :user, :bill, presence: {message: "can't be blank"} sig { returns(Bill) } def bill diff --git a/app/models/vote.rb b/app/models/vote.rb index 6d050b56..ccc11f1f 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -6,9 +6,17 @@ # id :integer not null, primary key # house_roll_call_vote_number :integer # senate_roll_call_vote_number :integer -# bill_id :integer not null # created_at :datetime not null # updated_at :datetime not null +# bill_id :integer not null +# +# Indexes +# +# index_votes_on_bill_id (bill_id) +# +# Foreign Keys +# +# bill_id (bill_id => bills.id) # # typed: true diff --git a/app/services/congress_legislator_vote_update_service.rb b/app/services/congress_legislator_vote_update_service.rb index 6c123610..0d3db5e0 100644 --- a/app/services/congress_legislator_vote_update_service.rb +++ b/app/services/congress_legislator_vote_update_service.rb @@ -63,6 +63,7 @@ def senator(vote) party: [vote.party, Legislator.to_party_name_from_char(T.let(vote.party, String))], title: "Sen." ).select(:id) + return nil if legislators.empty? if legislators.size == 1 diff --git a/app/services/score_updater_service.rb b/app/services/score_updater_service.rb index ced7a615..724d1046 100644 --- a/app/services/score_updater_service.rb +++ b/app/services/score_updater_service.rb @@ -63,17 +63,17 @@ def update_user_legislator_scores sig { returns(T::Array[District]) } def districts - @districts ||= bill_districts.select { |d| user_districts.include?(d) } + T.cast(@districts ||= bill_districts.select { |d| user_districts.include?(d) }, T::Array[District]) end sig { returns(T::Array[District]) } def bill_districts - @bill_districts ||= sway_locale.districts + T.cast(@bill_districts ||= sway_locale.districts, T::Array[District]) end sig { returns(T::Array[District]) } def user_districts - @user_districts ||= user.districts(sway_locale) + T.cast(@user_districts ||= user.districts(sway_locale), T::Array[District]) end sig { returns(User) } diff --git a/app/services/sway_push_notification_service.rb b/app/services/sway_push_notification_service.rb index be94e679..5807cf26 100644 --- a/app/services/sway_push_notification_service.rb +++ b/app/services/sway_push_notification_service.rb @@ -13,6 +13,7 @@ def initialize(subscription = nil, title:, body:) end def send_push_notification + Rails.logger.info("Sending push notifications.") subscriptions.send(iterator) do |sub| sub.send_web_push_notification(message) Rails.logger.info "Sent webpush to - #{sub.endpoint}" unless Rails.env.production? diff --git a/app/services/sway_registration_service.rb b/app/services/sway_registration_service.rb index a154601a..6742aa58 100644 --- a/app/services/sway_registration_service.rb +++ b/app/services/sway_registration_service.rb @@ -29,14 +29,13 @@ def initialize(user, address, sway_locale, invited_by_id:) sig { returns(T::Array[UserLegislator]) } def run - uls = user_legislators.map do |l| - UserLegislator.find_or_create_by!( - user: @user, - legislator: l - ) - end + uls = create_user_legislators - return uls if uls.blank? + if uls.blank? + Rails.logger.info("SwayRegistrationService.run - no UserLegislators created for User: #{@user.id}") + puts("SwayRegistrationService.run - no UserLegislators created for User: #{@user.id}") + return uls + end @user.is_registration_complete = true @user.save! @@ -46,6 +45,20 @@ def run uls end + sig { returns(T::Array[UserLegislator]) } + def create_user_legislators + district_legislators.map do |l| + ul = UserLegislator.find_or_initialize_by( + user: @user, + legislator: l + ) + unless ul.active + ul.active = true + end + ul.save! + end + end + private def districts @@ -53,12 +66,24 @@ def districts end sig { returns(T::Array[Legislator]) } - def user_legislators - return [] if @legislators.blank? + def district_legislators + if @legislators.blank? + Rails.logger.info("SwayRegistrationService.district_legislators - no Legislators in SwayLocale: #{@sway_locale.name}") + puts("SwayRegistrationService.district_legislators - no Legislators in SwayLocale: #{@sway_locale.name}") + return [] + end - T.let(@legislators, T::Array[Legislator]).filter do |legislator| - (legislator.district.region_code == address.region_code) && districts.include?(legislator.district.number) + dls = T.let(@legislators, T::Array[Legislator]).filter do |legislator| + legislator.active && (legislator.district.region_code == address.region_code) && districts.include?(legislator.district.number) + end + if dls.blank? + Rails.logger.info("SwayRegistrationService.district_legislators - no district_legislators found in SwayLocale: #{@sway_locale.name}") + puts("SwayRegistrationService.district_legislators - no district_legislators found in SwayLocale: #{@sway_locale.name}") + else + Rails.logger.info("SwayRegistrationService.district_legislators - #{dls.length} district_legislators found in SwayLocale: #{@sway_locale.name}") + puts("SwayRegistrationService.district_legislators - #{dls.length} district_legislators found in SwayLocale: #{@sway_locale.name}") end + dls end def create_invite diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e6097462..7442a9d0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -21,6 +21,7 @@ Visit the guide for more information: https://vite-ruby.netlify.app/guide/rails --> + <%= Sentry.get_trace_propagation_meta.html_safe %> @@ -31,7 +32,7 @@