From 60797b7022a599e1c06f0614f3514632ac3ae4ab Mon Sep 17 00:00:00 2001 From: Pietro Vieira Date: Thu, 10 Sep 2026 10:28:09 -0300 Subject: [PATCH 1/2] feat: setup Rails 8 app --- .dockerignore | 51 ++ .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 67 +++ .gitignore | 35 ++ .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 77 +++ Gemfile | 60 +++ Gemfile.lock | 473 ++++++++++++++++++ Rakefile | 6 + app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + app/controllers/application_controller.rb | 7 + app/controllers/concerns/.keep | 0 app/helpers/application_helper.rb | 2 + app/jobs/application_job.rb | 7 + app/mailers/application_mailer.rb | 4 + app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/views/layouts/application.html.erb | 28 ++ app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/bundler-audit | 6 + bin/ci | 6 + bin/dev | 2 + bin/docker-entrypoint | 8 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 35 ++ bin/thrust | 5 + config.ru | 6 + config/application.rb | 42 ++ config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 10 + config/ci.rb | 20 + config/credentials.yml.enc | 1 + config/database.yml | 40 ++ config/environment.rb | 5 + config/environments/development.rb | 78 +++ config/environments/production.rb | 89 ++++ config/environments/test.rb | 53 ++ config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 29 ++ .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/locales/en.yml | 31 ++ config/puma.rb | 42 ++ config/routes.rb | 14 + config/storage.yml | 27 + db/schema.rb | 14 + db/seeds.rb | 9 + lib/tasks/.keep | 0 log/.keep | 0 public/400.html | 135 +++++ public/404.html | 135 +++++ public/406-unsupported-browser.html | 135 +++++ public/422.html | 135 +++++ public/500.html | 135 +++++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 storage/.keep | 0 tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 73 files changed, 2236 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Rakefile create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/helpers/application_helper.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..325bfc036 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8dc432343 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..83610cfa4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..c7cb5c199 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + pull_request: + push: + branches: [ master ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + - name: Scan for known security vulnerabilities in gems used + run: bin/bundler-audit + + scan_js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + env: + RUBOCOP_CACHE_ROOT: tmp/rubocop + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Prepare RuboCop cache + uses: actions/cache@v4 + env: + DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} + with: + path: ${{ env.RUBOCOP_CACHE_ROOT }} + key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} + restore-keys: | + rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}- + + - name: Lint code for consistent style + run: bin/rubocop -f github + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fbcab405e --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore key files for decrypting credentials and more. +/config/*.key + diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..f9d86d4a5 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..d13e837c8 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..ca8af8efc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t fullstack_vanilla . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name fullstack_vanilla fullstack_vanilla + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libvips libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..f3e1431de --- /dev/null +++ b/Gemfile @@ -0,0 +1,60 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3", ">= 8.1.3.1" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +gem "jbuilder" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..aa2f0b02e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,473 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) + nokogiri (>= 1.8.5) + 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 (8.1.3.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.3.6) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) + timeout (>= 0.4.0) + activestorage (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activesupport (= 8.1.3.1) + marcel (~> 1.0) + activesupport (8.1.3.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + base64 (0.3.0) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.26.0) + msgpack (~> 1.5) + brakeman (8.0.6) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + ffi (1.17.4-x86_64-linux-gnu) + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.15.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (3.0.2) + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + mini_magick (5.4.0) + logger + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.4) + net-imap (0.6.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + ostruct (0.6.3) + parallel (2.2.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + bundler (>= 1.15.0) + railties (= 8.1.3.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.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.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) + rubocop (1.90.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.37.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger + securerandom (0.4.1) + solid_cable (4.0.2) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.9.6-x86_64-linux-gnu) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + thor (1.5.0) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.8.3) + +PLATFORMS + x86_64-linux-gnu + +DEPENDENCIES + bootsnap + brakeman + bundler-audit + debug + image_processing (~> 1.2) + importmap-rails + jbuilder + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + rubocop-rails-omakase + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) + actioncable (8.1.3.1) + actionmailbox (8.1.3.1) + actionmailer (8.1.3.1) + actionpack (8.1.3.1) + actiontext (8.1.3.1) + actionview (8.1.3.1) + activejob (8.1.3.1) + activemodel (8.1.3.1) + activerecord (8.1.3.1) + activestorage (8.1.3.1) + activesupport (8.1.3.1) + ast (2.4.3) + base64 (0.3.0) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.26.0) + brakeman (8.0.6) + builder (3.3.0) + bundler-audit (0.9.3) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + date (3.5.1) + debug (1.11.1) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + ffi (1.17.4-x86_64-linux-gnu) + fugit (1.13.0) + globalid (1.4.0) + i18n (1.15.2) + image_processing (1.14.0) + importmap-rails (2.2.3) + io-console (0.9.2) + irb (1.18.0) + jbuilder (2.15.1) + json (3.0.2) + kamal (2.12.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + mail (2.9.1) + marcel (1.2.1) + mini_magick (5.4.0) + mini_mime (1.1.5) + minitest (6.0.6) + msgpack (1.8.4) + net-imap (0.6.6) + net-pop (0.1.2) + net-protocol (0.3.0) + net-scp (4.1.0) + net-sftp (4.0.0) + net-smtp (0.5.1) + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-x86_64-linux-gnu) + ostruct (0.6.3) + parallel (2.2.0) + parser (3.3.12.0) + pp (0.6.4) + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + puma (8.0.2) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + rack-test (2.2.0) + rackup (2.3.1) + rails (8.1.3.1) + rails-dom-testing (2.3.0) + rails-html-sanitizer (1.7.1) + railties (8.1.3.1) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.2.0) + rdoc (8.0.0) + regexp_parser (2.12.0) + reline (0.7.0) + rubocop (1.90.0) + rubocop-ast (1.50.0) + rubocop-performance (1.27.0) + rubocop-rails (2.37.0) + rubocop-rails-omakase (1.1.0) + ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + securerandom (0.4.1) + solid_cable (4.0.2) + solid_cache (1.0.10) + solid_queue (1.7.0) + sqlite3 (2.9.6-x86_64-linux-gnu) + sshkit (1.25.1) + stimulus-rails (1.3.4) + thor (1.5.0) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + tzinfo (2.0.6) + unicode-display_width (3.2.0) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + websocket-driver (0.8.2) + websocket-extensions (0.1.5) + zeitwerk (2.8.3) + +BUNDLED WITH + 4.0.16 diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..9a5ea7383 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 000000000..fe93333c0 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 000000000..c3537563d --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 000000000..de6be7945 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 000000000..3c34c8148 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 000000000..b63caeb8a --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 000000000..5dc5c973f --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,28 @@ + + + + <%= content_for(:title) || "Fullstack Vanilla" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 000000000..3aac9002e --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 000000000..37f0bddbd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 000000000..1e9bc8f96 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "FullstackVanilla", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "FullstackVanilla.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 000000000..b3a13fb7b --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 000000000..ace1c9ba0 --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 000000000..e2ef22690 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 000000000..4137ad5bb --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..5f91c2054 --- /dev/null +++ b/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 000000000..ed31659f4 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/rails b/bin/rails new file mode 100755 index 000000000..efc037749 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 000000000..4fbf10b96 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..5a2050471 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..81be011e8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 000000000..36bde2d83 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 000000000..4a3c09a68 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 000000000..4ebdc1307 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,42 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_mailbox/engine" +require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module FullstackVanilla + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Don't generate system test files. + config.generators.system_tests = nil + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 000000000..988a5ddc4 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 000000000..e74b3af94 --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 000000000..4fd1761fd --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: fullstack_vanilla_production diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 000000000..239b34398 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,20 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..800094207 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +GcMOS9U82WOmXLbWkCdnkMWUAD3QYQfQ09c/JDVPKKdweUudowKf55EYO6J8E7FVfCNzAQQHyAGHc2rIjsvW4WLhZttKpuvt718LT39sKnHE81Fml+tCrqL1UmVKA+Sh0M3wkUvedBwhEurX9CSQ3kUHb0f7yXa9F0E8SqAImwnt8PvlGyFjCTMq4KvqESr6prn1fJGyeJcWtOnXf6yvWymlnJfIdJXVA4mX4HnswQjNj1mva73/JvdvP5fGui0WyQ/TW0Mn45eJxspMRJHsKfRwvRu6ElYSTVG3Ad/FaMMCOYFJwLRr6dEguMz/GpFC3NWrQLFZBtQOFHqyzzd5SkfKvuIFURVeVpUd05uuzOTVsr3xRwhtpzd1WF1DOkIyf/5TlhPtqbpJgDAMtqyuwD7qP9rjLhuMRCXsU5pE/8CAAKBBViuVOYhJ3V2EFVMrDokPoUdYX9GvCgaeuQRDoUbAH9umTS6JN0NcdH9v2Zu2ogpriq8kT0Bl--yYLQ2W9aCXG70pyp--da64XCuwB8aDgTkWidiZKQ== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..302d638c9 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,40 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +# Store production database in the storage/ directory, which by default +# is mounted as a persistent Docker volume in config/deploy.yml. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..75243c3d0 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,78 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 000000000..cb0241df6 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,89 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + # config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + # config.cache_store = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + # config.active_job.queue_adapter = :resque + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 000000000..c2095b117 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 000000000..487324424 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..d51d71397 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 000000000..c0b717f7e --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 000000000..3860f659e --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 000000000..6c349ae5e --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 000000000..38c4b8659 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,42 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 000000000..48254e88e --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,14 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 000000000..927dc537c --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,27 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..03e73681a --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,14 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 0) do +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 000000000..4fbd6ed97 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/log/.keep b/log/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/public/400.html b/public/400.html new file mode 100644 index 000000000..640de0339 --- /dev/null +++ b/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 000000000..d7f0f1422 --- /dev/null +++ b/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 000000000..43d2811e8 --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 000000000..f12fb4aa1 --- /dev/null +++ b/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 000000000..e4eb18a75 --- /dev/null +++ b/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 000000000..04b34bf83 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 000000000..e69de29bb From 98991f8ad16e27bfb2365ccf18bc82e7f7b2ee5c Mon Sep 17 00:00:00 2001 From: Pietro Vieira Date: Thu, 10 Sep 2026 16:17:26 -0300 Subject: [PATCH 2/2] feat: implementa sistema completo de usuarios, auth e imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - autenticação com sessions, registrations, passwords e concerns/authentication - perfis (profiles) e controle de acesso admin via UserRole - CRUD de usuarios no namespace admin com toggle_role e avatar (Active Storage) - dashboard admin em real-time com ActionCable e Stimulus - importação de usuarios via CSV/XLSX com SpreadsheetUserImporter, Job assincrono e progresso via ActionCable - API publica versionada api/v1/users com serializers - internacionalização pt-BR, Tailwind + Importmap + Hotwire - seeds para admin/user padrão, migrations e schemas (users, sessions, imports, roles) - testes unitarios, integração e sistema cobrindo controllers, models e services --- .desenvolvimento.md | 43 ++ Dockerfile | 6 +- Gemfile | 22 +- Gemfile.lock | 377 ++++++++++++------ .../usuarios_exemplo.csv | 6 + .../usuarios_exemplo.xlsx | Bin 0 -> 5190 bytes README.md | 7 + app/assets/builds/.keep | 0 app/assets/builds/tailwind.css | 2 + app/assets/stylesheets/application.css | 75 +++- app/assets/tailwind/application.css | 1 + app/channels/application_cable/channel.rb | 4 + app/channels/application_cable/connection.rb | 19 + app/channels/dashboard_channel.rb | 11 + app/channels/import_channel.rb | 7 + app/controllers/admin/base_controller.rb | 5 + .../admin/dashboards_controller.rb | 8 + app/controllers/admin/imports_controller.rb | 38 ++ app/controllers/admin/users_controller.rb | 82 ++++ app/controllers/api/v1/base_controller.rb | 2 + app/controllers/api/v1/users_controller.rb | 42 ++ app/controllers/application_controller.rb | 4 + app/controllers/concerns/authentication.rb | 71 ++++ app/controllers/home_controller.rb | 11 + app/controllers/passwords_controller.rb | 36 ++ app/controllers/profiles_controller.rb | 40 ++ app/controllers/registrations_controller.rb | 32 ++ app/controllers/sessions_controller.rb | 22 + app/helpers/application_helper.rb | 8 + app/javascript/application.js | 3 + app/javascript/controllers/application.js | 9 + .../controllers/avatar_preview_controller.js | 28 ++ .../controllers/dashboard_controller.js | 30 ++ .../controllers/form_validation_controller.js | 27 ++ .../controllers/import_progress_controller.js | 48 +++ app/javascript/controllers/index.js | 4 + app/jobs/process_import_job.rb | 8 + app/mailers/passwords_mailer.rb | 6 + app/models/current.rb | 4 + app/models/import.rb | 52 +++ app/models/session.rb | 3 + app/models/user.rb | 60 +++ app/models/user_role.rb | 17 + app/serializers/user_role_serializer.rb | 5 + app/serializers/user_serializer.rb | 21 + app/services/spreadsheet_user_importer.rb | 93 +++++ app/views/admin/dashboards/show.html.erb | 52 +++ app/views/admin/imports/index.html.erb | 33 ++ app/views/admin/imports/new.html.erb | 35 ++ app/views/admin/imports/show.html.erb | 38 ++ app/views/admin/users/edit.html.erb | 10 + app/views/admin/users/index.html.erb | 38 ++ app/views/admin/users/new.html.erb | 9 + app/views/admin/users/show.html.erb | 17 + app/views/layouts/application.html.erb | 53 ++- app/views/passwords/edit.html.erb | 17 + app/views/passwords/new.html.erb | 16 + app/views/passwords_mailer/reset.html.erb | 5 + app/views/passwords_mailer/reset.text.erb | 2 + app/views/profiles/edit.html.erb | 9 + app/views/profiles/show.html.erb | 18 + app/views/pwa/manifest.json.erb | 4 +- app/views/registrations/new.html.erb | 9 + app/views/sessions/new.html.erb | 25 ++ app/views/shared/_avatar.html.erb | 6 + app/views/shared/_user_form.html.erb | 86 ++++ bin/docker-entrypoint | 3 + bin/importmap | 4 + config/application.rb | 21 +- config/cable.yml | 13 +- config/cache.yml | 16 + config/database.yml | 4 + config/importmap.rb | 8 + config/initializers/frontend.rb | 2 + config/initializers/json_patch.rb | 23 ++ config/locales/pt.yml | 132 ++++++ config/queue.yml | 18 + config/routes.rb | 30 +- db/cable_schema.rb | 11 + db/cache_schema.rb | 12 + .../20260909123106_create_user_roles.rb | 10 + ...te_active_storage_tables.active_storage.rb | 57 +++ db/migrate/20260910121556_create_users.rb | 14 + db/migrate/20260910121558_create_sessions.rb | 11 + db/migrate/20260910122000_create_imports.rb | 17 + db/queue_schema.rb | 160 ++++++++ db/schema.rb | 78 +++- db/seeds.rb | 33 +- test/application_system_test_case.rb | 5 + test/controllers/.keep | 0 .../admin/dashboards_controller_test.rb | 18 + .../admin/imports_controller_test.rb | 28 ++ .../admin/users_controller_test.rb | 45 +++ .../api/v1/users_controller_test.rb | 20 + test/controllers/profiles_controller_test.rb | 26 ++ .../registrations_controller_test.rb | 20 + test/controllers/sessions_controller_test.rb | 20 + test/fixtures/files/.keep | 0 test/fixtures/imports.yml | 1 + test/fixtures/sessions.yml | 1 + test/fixtures/user_roles.yml | 9 + test/fixtures/users.yml | 11 + test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/models/import_test.rb | 13 + test/models/user_role_test.rb | 13 + test/models/user_test.rb | 53 +++ .../spreadsheet_user_importer_test.rb | 52 +++ test/system/admin_users_system_test.rb | 16 + test/system/authentication_system_test.rb | 35 ++ test/test_helper.rb | 33 ++ vendor/javascript/.keep | 0 vendor/javascript/@rails--actioncable.js | 4 + 115 files changed, 2797 insertions(+), 184 deletions(-) create mode 100644 .desenvolvimento.md create mode 100644 PLANILHA_EXEMPLOS_IMPORTACAO/usuarios_exemplo.csv create mode 100644 PLANILHA_EXEMPLOS_IMPORTACAO/usuarios_exemplo.xlsx create mode 100644 app/assets/builds/.keep create mode 100644 app/assets/builds/tailwind.css create mode 100644 app/assets/tailwind/application.css create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/dashboard_channel.rb create mode 100644 app/channels/import_channel.rb create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/controllers/admin/dashboards_controller.rb create mode 100644 app/controllers/admin/imports_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/controllers/api/v1/base_controller.rb create mode 100644 app/controllers/api/v1/users_controller.rb create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/home_controller.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/profiles_controller.rb create mode 100644 app/controllers/registrations_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/avatar_preview_controller.js create mode 100644 app/javascript/controllers/dashboard_controller.js create mode 100644 app/javascript/controllers/form_validation_controller.js create mode 100644 app/javascript/controllers/import_progress_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/jobs/process_import_job.rb create mode 100644 app/mailers/passwords_mailer.rb create mode 100644 app/models/current.rb create mode 100644 app/models/import.rb create mode 100644 app/models/session.rb create mode 100644 app/models/user.rb create mode 100644 app/models/user_role.rb create mode 100644 app/serializers/user_role_serializer.rb create mode 100644 app/serializers/user_serializer.rb create mode 100644 app/services/spreadsheet_user_importer.rb create mode 100644 app/views/admin/dashboards/show.html.erb create mode 100644 app/views/admin/imports/index.html.erb create mode 100644 app/views/admin/imports/new.html.erb create mode 100644 app/views/admin/imports/show.html.erb create mode 100644 app/views/admin/users/edit.html.erb create mode 100644 app/views/admin/users/index.html.erb create mode 100644 app/views/admin/users/new.html.erb create mode 100644 app/views/admin/users/show.html.erb create mode 100644 app/views/passwords/edit.html.erb create mode 100644 app/views/passwords/new.html.erb create mode 100644 app/views/passwords_mailer/reset.html.erb create mode 100644 app/views/passwords_mailer/reset.text.erb create mode 100644 app/views/profiles/edit.html.erb create mode 100644 app/views/profiles/show.html.erb create mode 100644 app/views/registrations/new.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/shared/_avatar.html.erb create mode 100644 app/views/shared/_user_form.html.erb create mode 100755 bin/importmap create mode 100644 config/cache.yml create mode 100644 config/importmap.rb create mode 100644 config/initializers/frontend.rb create mode 100644 config/initializers/json_patch.rb create mode 100644 config/locales/pt.yml create mode 100644 config/queue.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/migrate/20260909123106_create_user_roles.rb create mode 100644 db/migrate/20260909123215_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20260910121556_create_users.rb create mode 100644 db/migrate/20260910121558_create_sessions.rb create mode 100644 db/migrate/20260910122000_create_imports.rb create mode 100644 db/queue_schema.rb create mode 100644 test/application_system_test_case.rb create mode 100644 test/controllers/.keep create mode 100644 test/controllers/admin/dashboards_controller_test.rb create mode 100644 test/controllers/admin/imports_controller_test.rb create mode 100644 test/controllers/admin/users_controller_test.rb create mode 100644 test/controllers/api/v1/users_controller_test.rb create mode 100644 test/controllers/profiles_controller_test.rb create mode 100644 test/controllers/registrations_controller_test.rb create mode 100644 test/controllers/sessions_controller_test.rb create mode 100644 test/fixtures/files/.keep create mode 100644 test/fixtures/imports.yml create mode 100644 test/fixtures/sessions.yml create mode 100644 test/fixtures/user_roles.yml create mode 100644 test/fixtures/users.yml create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/models/import_test.rb create mode 100644 test/models/user_role_test.rb create mode 100644 test/models/user_test.rb create mode 100644 test/services/spreadsheet_user_importer_test.rb create mode 100644 test/system/admin_users_system_test.rb create mode 100644 test/system/authentication_system_test.rb create mode 100644 test/test_helper.rb create mode 100644 vendor/javascript/.keep create mode 100644 vendor/javascript/@rails--actioncable.js diff --git a/.desenvolvimento.md b/.desenvolvimento.md new file mode 100644 index 000000000..a0cb7525d --- /dev/null +++ b/.desenvolvimento.md @@ -0,0 +1,43 @@ +# Gerais do App + +## Coisas que usei IA + +### exemplos de CSV e XLSX + - eu criei com IA exemplos de importação. (PLANILHA_EXEMPLOS_IMPORTACAO/*) + +### Testes completos + - por que cobre todos os casos que eu não pensei e também por ser repetivos... + +### Docker e Docker compose + - para DevOps eu usei pra acelerar na entrega de algo simples. + +### Git Messages + - Para mensagens de git commit -m "MESSAGE" uso IA no dia a dia que me da o contexto geral do que foi feito. + +## UX/Design + - Deixei ux design bem básico pra focar no resto, e poderia ter usado IA também mas não quis, fiz coisas simples no tailwindcss... + +## Utilizei stimulus/hotwire/ActionCable + - Loading do import dos CSV.... (assincrona) + - Dashboard (Real-time), como por exemplo contador + - Preview do Avatar. + - Validação do Front nos inputs.... + +## Utilizei ActionStorage + - Para salvar os uploads de arquivos.... + - Para salvar os arquivos de Avatar/Importações. + + +# Database (Modelagem) + - Fiz um model User (cadastros) e UserRole (para os perfis de usuários) + +# Reset app + - rails db:drop + - rails db:create + - rails db:migrate + - rails db:seed + + - Criei também uma API/v1/users publica de exemplo rest com serialize. (com namespace api/v1 pra versionamento) + + - http://localhost:3000/api/v1/users: + ```curl -s http://localhost:3000/api/v1/users``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ca8af8efc..fab3b6832 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,6 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems -COPY vendor/* ./vendor/ COPY Gemfile Gemfile.lock ./ RUN bundle install && \ @@ -62,7 +61,9 @@ FROM base # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + mkdir -p /rails/storage /rails/tmp/pids /rails/log && \ + chown -R rails:rails /rails/storage /rails/tmp /rails/log USER 1000:1000 # Copy built artifacts: gems, application @@ -74,4 +75,5 @@ ENTRYPOINT ["/rails/bin/docker-entrypoint"] # Start server via Thruster by default, this can be overwritten at runtime EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD curl -f http://localhost:80/up || exit 1 CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile index f3e1431de..3f0000e0f 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ gem "stimulus-rails" gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] @@ -40,6 +40,12 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 1.2" +gem "fast_jsonapi" + +# Pin json < 3.0 para compatibilidade com Ruby 4.0 até ActiveSupport corrigir kwargs +# json 3.0.2 quebrou ActiveSupport::JSON.decode (JSON.parse com Hash posicional) +gem "json", "~> 2.7" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" @@ -52,9 +58,23 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false + + gem "rspec-rails", "~> 6.1.1" + gem "faker", "~> 3.2.1" + gem "simplecov", require: false end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end + +gem "tailwindcss-rails", "~> 4.6" +gem "roo", "~> 3.0" +gem "csv", "~> 3.3" diff --git a/Gemfile.lock b/Gemfile.lock index aa2f0b02e..8c229a42f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,8 +75,11 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) + bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) bigdecimal (4.1.2) bindex (0.8.1) @@ -88,13 +91,24 @@ GEM bundler-audit (0.9.3) bundler (>= 1.2.0) thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.7) + csv (3.3.6) date (3.5.1) debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -102,7 +116,16 @@ GEM erubi (1.13.1) et-orbi (1.4.2) tzinfo + faker (3.2.3) + i18n (>= 1.8.11, < 2) + fast_jsonapi (1.5) + activesupport (>= 4.2) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) fugit (1.13.0) et-orbi (~> 1.4) raabro (~> 1.4) @@ -126,7 +149,7 @@ GEM jbuilder (2.15.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (3.0.2) + json (2.21.2) kamal (2.12.0) activesupport (>= 7.0) base64 (~> 0.2) @@ -151,6 +174,7 @@ GEM net-pop net-smtp marcel (1.2.1) + matrix (0.4.3) mini_magick (5.4.0) logger mini_mime (1.1.5) @@ -173,8 +197,18 @@ GEM net-protocol net-ssh (7.3.3) nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) ostruct (0.6.3) parallel (2.2.0) parser (3.3.12.0) @@ -188,6 +222,7 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack + public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) raabro (1.5.0) @@ -244,6 +279,30 @@ GEM regexp_parser (2.12.0) reline (0.7.0) io-console (~> 0.5) + rexml (3.4.4) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) + nokogiri (~> 1) + rubyzip (>= 3.0.0, < 4.0.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.7) rubocop (1.90.0) json (>= 2.3) language_server-protocol (~> 3.17.0.2) @@ -276,7 +335,15 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger + rubyzip (3.6.0) securerandom (0.4.1) + selenium-webdriver (4.48.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + simplecov (1.2.0) solid_cable (4.0.2) actioncable (>= 7.2) activejob (>= 7.2) @@ -293,7 +360,12 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-aarch64-linux-musl) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm-linux-musl) sqlite3 (2.9.6-x86_64-linux-gnu) + sqlite3 (2.9.6-x86_64-linux-musl) sshkit (1.25.1) base64 logger @@ -303,7 +375,17 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) + tailwindcss-rails (4.6.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.3.3) + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) + tailwindcss-ruby (4.3.3-aarch64-linux-musl) + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) + tailwindcss-ruby (4.3.3-x86_64-linux-musl) thor (1.5.0) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) thruster (0.1.26-x86_64-linux) timeout (0.6.1) tsort (0.2.0) @@ -321,153 +403,218 @@ GEM actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + websocket (1.2.11) websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) zeitwerk (2.8.3) PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit + capybara + csv (~> 3.3) debug + faker (~> 3.2.1) + fast_jsonapi image_processing (~> 1.2) importmap-rails jbuilder + json (~> 2.7) kamal propshaft puma (>= 5.0) rails (~> 8.1.3, >= 8.1.3.1) + roo (~> 3.0) + rspec-rails (~> 6.1.1) rubocop-rails-omakase + selenium-webdriver + simplecov solid_cable solid_cache solid_queue sqlite3 (>= 2.1) stimulus-rails + tailwindcss-rails (~> 4.6) thruster turbo-rails tzinfo-data web-console CHECKSUMS - action_text-trix (2.1.19) - actioncable (8.1.3.1) - actionmailbox (8.1.3.1) - actionmailer (8.1.3.1) - actionpack (8.1.3.1) - actiontext (8.1.3.1) - actionview (8.1.3.1) - activejob (8.1.3.1) - activemodel (8.1.3.1) - activerecord (8.1.3.1) - activestorage (8.1.3.1) - activesupport (8.1.3.1) - ast (2.4.3) - base64 (0.3.0) - bcrypt_pbkdf (1.1.2) - bigdecimal (4.1.2) - bindex (0.8.1) - bootsnap (1.26.0) - brakeman (8.0.6) - builder (3.3.0) - bundler-audit (0.9.3) - concurrent-ruby (1.3.8) - connection_pool (3.0.2) - crass (1.0.7) - date (3.5.1) - debug (1.11.1) - dotenv (3.2.0) - drb (2.2.3) - ed25519 (1.4.0) - erb (6.0.7) - erubi (1.13.1) - et-orbi (1.4.2) - ffi (1.17.4-x86_64-linux-gnu) - fugit (1.13.0) - globalid (1.4.0) - i18n (1.15.2) - image_processing (1.14.0) - importmap-rails (2.2.3) - io-console (0.9.2) - irb (1.18.0) - jbuilder (2.15.1) - json (3.0.2) - kamal (2.12.0) - language_server-protocol (3.17.0.6) - lint_roller (1.1.0) - logger (1.7.0) - loofah (2.25.2) - mail (2.9.1) - marcel (1.2.1) - mini_magick (5.4.0) - mini_mime (1.1.5) - minitest (6.0.6) - msgpack (1.8.4) - net-imap (0.6.6) - net-pop (0.1.2) - net-protocol (0.3.0) - net-scp (4.1.0) - net-sftp (4.0.0) - net-smtp (0.5.1) - net-ssh (7.3.3) - nio4r (2.7.5) - nokogiri (1.19.4-x86_64-linux-gnu) - ostruct (0.6.3) - parallel (2.2.0) - parser (3.3.12.0) - pp (0.6.4) - prettyprint (0.2.0) - prism (1.9.0) - propshaft (1.3.2) - puma (8.0.2) - raabro (1.5.0) - racc (1.8.1) - rack (3.2.7) - rack-session (2.1.2) - rack-test (2.2.0) - rackup (2.3.1) - rails (8.1.3.1) - rails-dom-testing (2.3.0) - rails-html-sanitizer (1.7.1) - railties (8.1.3.1) - rainbow (3.1.1) - rake (13.4.2) - rbs (4.2.0) - rdoc (8.0.0) - regexp_parser (2.12.0) - reline (0.7.0) - rubocop (1.90.0) - rubocop-ast (1.50.0) - rubocop-performance (1.27.0) - rubocop-rails (2.37.0) - rubocop-rails-omakase (1.1.0) - ruby-progressbar (1.13.0) - ruby-vips (2.3.0) - securerandom (0.4.1) - solid_cable (4.0.2) - solid_cache (1.0.10) - solid_queue (1.7.0) - sqlite3 (2.9.6-x86_64-linux-gnu) - sshkit (1.25.1) - stimulus-rails (1.3.4) - thor (1.5.0) - thruster (0.1.26-x86_64-linux) - timeout (0.6.1) - tsort (0.2.0) - turbo-rails (2.0.23) - tzinfo (2.0.6) - unicode-display_width (3.2.0) - unicode-emoji (4.2.0) - uri (1.1.1) - useragent (0.16.11) - web-console (4.3.0) - websocket-driver (0.8.2) - websocket-extensions (0.1.5) - zeitwerk (2.8.3) + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b + actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b + brakeman (8.0.6) sha256=759cc69341115e6c2dcd47b6fd8649a0b9bd540e3585ac8a0a94e31c66fee386 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + faker (3.2.3) sha256=ffaf8d7a511f4ae5f5291b92fb306a386a6296765df204a0a4d89750d0813ce7 + fast_jsonapi (1.5) sha256=22c88f388e4d275f87815b7818fb013a06d43fb7cdf893b72e89aae8e3e87613 + ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df + ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 + ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 + ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 + ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d + ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_magick (5.4.0) sha256=f120af581d9ed4ec52c57f35a67a605d112bb1d8f582d1415b147fda42d11d78 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.3) sha256=831def58b2c51dcef66ec00d29397d4f210de89c19fe78f95873ca30f386e86a + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-rails (6.1.5) sha256=d11afce893ceb6e2c3c11db280f83dee6d0120d150228cef6b989d37c7394c4b + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 + simplecov (1.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c + sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9 + sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec + sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33 + sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634 + sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 + tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a + tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) sha256=2337017ff8b02698480eae1e9637cf01faa0e4824db89d067a13c5a5ee38c9b2 + tailwindcss-ruby (4.3.3-x86_64-linux-musl) sha256=27d478c417bcf73828e5b544744c5bdfd5b5cb54f1a266fc4f185281c32efe6c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 + thruster (0.1.26-aarch64-linux) sha256=2171cb34928c0250830008f535c4ab2ee57846cc3f5d3e96c3475f7b3de7a541 + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 BUNDLED WITH 4.0.16 diff --git a/PLANILHA_EXEMPLOS_IMPORTACAO/usuarios_exemplo.csv b/PLANILHA_EXEMPLOS_IMPORTACAO/usuarios_exemplo.csv new file mode 100644 index 000000000..c535ab9d6 --- /dev/null +++ b/PLANILHA_EXEMPLOS_IMPORTACAO/usuarios_exemplo.csv @@ -0,0 +1,6 @@ +full_name,email,password,role,avatar_url +Ana Souza,ana.souza@example.com,password123,no-admin,https://i.pravatar.cc/150?u=ana +Bruno Lima,bruno.lima@example.com,password123,admin,https://i.pravatar.cc/150?u=bruno +Carla Mendes,carla.mendes@example.com,,no-admin, +Diego Alves,diego.alves@example.com,password123,no-admin,https://i.pravatar.cc/150?u=diego +Elena Rocha,elena.rocha@example.com,password123,yes,https://i.pravatar.cc/150?u=elena diff --git a/PLANILHA_EXEMPLOS_IMPORTACAO/usuarios_exemplo.xlsx b/PLANILHA_EXEMPLOS_IMPORTACAO/usuarios_exemplo.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..9664f0ea32cc6e87afbf6cc625c46b767182fd04 GIT binary patch literal 5190 zcmZ`-1yodx)}0{*h8R*>x*MdsYXGT%kp>BA5C#MRrKF@mQW~UNT0mMFNeKx70Vy4% z|54Zb{N#Uc?z;D`yVmS8_w2P#%+^*%MI#0P0Ji{l@3EUG^=YakA)khkCjs)bcCpfS zcX9RLv2bqy-p65XuNgDw}= z=pC5NWZHkFu;Ar>vX6Xq4io?Y{O=U3T-M-qw~+O(Z`<*299{ z8MF#hafU%BgV*yH8yllxVOy97eBRd=U%g$U@$Q|6^NeFgS@SJMRqP~8S#;wm$f7X% z%M{%cWjZTqIv!_B%W}h)-Oih5(95Xoq+`od=>&0h$yqyD)lGp(PT{?jZ7bcjyZrC% z-r`Xs5@ixmpyFDfqUwh$*r^u5;|2RDX(6el0hS(^v!bHRE%~7qtYKoCh16)R($a08 zAK@&p+-vLIHmr$<0EKt?Ps`f_W~FKxRzEbac!#XObkyBA3`<|keGgmKpY^fwM$l54 zp)FuMSyVhn|C^nc_cIs>FaUrI765<<$qw9+$J5Tn$>#T!?=x&#{M_W6jS^5&L^ z&+%7o%dODx#3Q)*9M8gZOC!(HiXlHESeBQ(sPf<~c7cXzy|F!@Tf~2Wh_L(P)5u%u zaN?oq`ztUC9){^Jbqk9I!S;?hx0S8U{Y^RTWXAM#j9tv`)NRlT{2&?9i?+!F5*f-e zH-*<7ojUeh%r8mVjBblj^9MQeQbO+pjJOo7LyW^e>2tHKw@UU-uLM8C&fPW%- zvnnEQlH?XYuDDnxbi~UK@oq0WYH;3D(1~JY?GP@~FfYE3CWXd2i{M=Z-xy z%VovG{B2S@Iiks;_{FJWeW98-dl$L>f z0pCo2v5|*~0rsW9qP>>%(S!?+t@Z4pgc(h_5!Y5utiy8#m_-98692EigIUYSwukn5n9U53^OHfYw2eal^7MX`?;Fe!6Nml zxco%Yw=zRh9ki)X^0D}mb3%XA^_GEGi2NJdZ}l%nho6+Xq)8g)Kvn01e-J29tY&z8 z_850O$1G49N9ebH%u9ii08?%+3%(ZO?3soqy*(_?kf0_a98Sc+kPS^?WF{;TeWIyC zbj49Hf0Z#QbCAfxc%~Nj!oHTF$dh?^OMs$Nsgm+hnB9bg?-lxKh7dMY93?f1EoH3| zU9f%20Zk=iOj>ytm_ts=lKd`(dp&7TgIh_kxiMkyI2W6H1UW@82Dcdo(f~ zhX;-~6-It{Br+DGp z+x&u2-y{+06PagxX$LEE+N`E|kD;mJ< zKU%Qg^i48nqEVQ^-?noy61Jq|410H8!8bo6$jxeH=sw)vr&pQ4HY=L_cK6awD(i6X zY>qLwGR=sKfizY$?w(fVeq?wJ%Ce}9+ zu=VZA98W3E(NhVt&AMS60r^IE^)C&H?e)weYTv5gyFzG&K;pI%@zf38uc(F25hh;i z3Qv?J zOC(eGg&*b8ea#6?9qpD6^LcERYMa!nElBL-SjNby)M)-@=8%(k#oC26D)Pi&H;C>V zLazpV6#k|b(Gp)9KHI{hWiAjFCoJ5Ea8=V}@55$#MujiE*I9~pr}@#P>wq%;hWMu0 zHxv1aCy2$hGv@?x6^#s^9x%{T5rrX%3WJo4e_i`)d@KeLa5!;5;Q-~Sbs<$?>KRVk z5vYlcWTae5G(eH_St!JGf{Bngq6&mgN9Mk^zn|dX9W`^t8RQhK$8Y_CY*7Arr=)

6Bz0nWWNj-pdu5+AxoF-#1-~W!KPY znp6H|QP%~rB~;VTM4a;ZPdfc0A_BpsmhH`zTBl4I;&qP?gYKkXOfTdk7FR%iQ>DfQ z)*N7Ibxc3ncnqs3*f&A57sQt3h1KP~^L!wmK;V2fK!V6~IP#IBbqWgrxcz&8 zc)Pei^suwB@$}&Nb^A3%LXyo~=6Q*GIwF!zm|_9x?B=M0b*iO$_45em%XPW3Gpz^W z1&c-5)N;M4E=O{XeH}#E3F^D2I@`9oD9|JJH-eGHbq&p`gb68kil6JFPb|)Ha1}o? z@mJ9(2qML0gvZAy)hcK+=WVo-x6BMRz2>qd{3^s(PoURP9#pzUPocaSgz3dvPOFl9 z96_@J-aYwZ+G09ULaY%esG*u`(GhO2mcI9X)P?!N+*OdwX*F{<(HD_f*+WlpiVh6> z=!^b^o2Lpm(iRKrtuzq$LbKgDgfP1FSX=a&(?O-|38?8xj@*)0z@D8zQqbc`Vdk^+%suT@~*Ly2b4e7^c z_Q5IV>nCAb1DZbrk_h!Cse#R%`IIhPxFsZn|U4eiZ%O-1m0UINcGS_V&K!!|CUnJXxx2`7+c#FE?&GubJPVKnp}y#uw~_~25~6}F zXJH2>F4f!q+d|B8+e~EQxzhU|b^OgI2=E@C#OhouwQ=~1$!CZe=ns>&=uSH|Y1Vr7 zagRND+R|Y-m?i#Xc@NKhNcwMO`yJv?T9modexR1vHF*49=xZ@Y@)QCJTE02 zwWX72v9mdI@`atiKC&p~d>81D$4ylU26@`fA~D#sg~I#jcI3>OS|Q3`Ce zAtc4;JAXJe^dBu2)<0F;b-deb77XWyuk`(4KM$g{Vo(In>8iNUiml_xc!VN}jhI)FyyoGF2rE#=3X z=My!vJAD`n@2jTLZ(&7SdJ>U*yDn7Oi>7{zdnxQk|dnl?_Vc9A^Mg zG?k}Nrf*kGuWs!HiR~NCqTyiZgy1TDny=pCHh}^LiUKtcW2++KlNtjti={mpA3I(W zu;)1^`^dH4Xj&PIrJnN+V-2(D!+0ViwtPHc5|-hT6*WU+eMOyQJ>?y-m$I7Q%Q+8I z5K0@NAa*>Zvli3I$wJ}vHOW^G&c;KvlP1zt^wmx%;yjmQb@aFGb=_iRHG}4ljynY)dn2M_npYi4|tb2l$YCZK05 zix+c@E?mIokQD6{XpoCPONbt!nAuS|U*HytL+&FfLHLozUS>}uzag^Z_9ekRd*csp zfGQ14FYHI+7SB*7@)lN(oFE2`r;^ia|?#evCO>Vg33K1A%~cBK`4 zoiA+b)Xh#0JLaPmQG__NHS zb;cpcWG|A~X=PflnEA>dTc9x0rQY6^e7N|*n9wk$rL&!;QNM@L6t4Ew(c~+4q`;w@ zmZwI0D-NGsgk?EkY1Hs%!JFgGXu#inH0<^Y{^`rl_V0lr2{1>45Fl) zZ^(B>)9biI(?CSa`;ue*&-e#)R=V#6A zk(+1M*dNGKuAc{4AUmNdWCM@%?BW zO?qxQlGz_fmMMNS{98@`=J}Vh>fYT>+8$h42dgw&qs^)ej=AOA_w@qttML~iq>puBoyl!U3Lw3JMXOyX(ZIQLGqV!>U*YxxziKZfqeou|DzS$OK1GfB%hz z$-6jv+Bkcf>G`}+66+?>!?g_PzvCayp$EsD~s1Y6^Y1p&ys z+ABhm5Y009QtN0|O4-s2aMf1k_t`{h9Y10eeN2cYH9fqdj!sndBCUa5tv&%pa5A*< zOuLf^{+ji1;?4(wI(L^6__hzF=7HwRx0Xf{(OvHy)X4LM)CgyKI(KLjzbF(pd5oFc z(sM(#BT9qspHjPic`PoYrX*h-_uRC=Dru`+M$GuF@BHc@x%>hJx>#}BS~7(dN=Y^9 z9owC8Pd-iNEZ(Tmmbr`02iX0Cn%H2`YP5j9Ooo9)eQO34>%vMnUCsC1b6E)NS$o{@ z+9k+dcsegupMZo9CPtv9VNG5?O#Hjz<^FeCn*C!Ck z@BepzZlZ76Ex)k^VE>BpXA`&yz1h0|K;g(O;$MyYCh%q}`~$SW`{&L7(-?1pZ&vvqa5u88 rB6<5CHGebC%|iMUrx@w~{g<-RR!2wLM*sj8@{mF{@uQy}8{q!{d;{1; literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 7829f14ff..0f7132891 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,10 @@ These are mandatory. Failing any of them will invalidate your submission. - Code's Semantics, Cleanness, and Maintainability (Senior-level object-oriented design and clean React/Stimulus component lifecycle). - Modern Rails 8 idiom usage (e.g., Strict structural params handling, Solid architecture separation). - Basic Security testing against traditional vectors (SQLi, XSS, XSRF) and proper encryption of sensitive DB columns where applicable. + + +--------------------------------------------------------------------------------------------------------------------------- + +# Detalhes do Projeto + + - .desenvolvimento.md \ No newline at end of file diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css new file mode 100644 index 000000000..f57255dd1 --- /dev/null +++ b/app/assets/builds/tailwind.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-green-50:oklch(98.2% .018 155.826);--color-green-500:oklch(72.3% .219 149.579);--color-blue-600:oklch(54.6% .245 262.881);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-white:#fff;--spacing:.25rem;--container-4xl:56rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-bold:700;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-5{margin-block:calc(var(--spacing) * 5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-3{height:calc(var(--spacing) * 3)}.h-10{height:calc(var(--spacing) * 10)}.h-16{height:calc(var(--spacing) * 16)}.w-10{width:calc(var(--spacing) * 10)}.w-16{width:calc(var(--spacing) * 16)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-green-50{background-color:var(--color-green-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.object-cover{object-fit:cover}.p-0{padding:0}.p-4{padding:calc(var(--spacing) * 4)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.leading-10{--tw-leading:calc(var(--spacing) * 10);line-height:calc(var(--spacing) * 10)}.leading-\[4rem\]{--tw-leading:4rem;line-height:4rem}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.whitespace-nowrap{white-space:nowrap}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-green-500{color:var(--color-green-500)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.underline{text-decoration-line:underline}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.focus\:outline-blue-600:focus{outline-color:var(--color-blue-600)}@media (min-width:40rem){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:w-2\/3{width:66.6667%}}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333c0..8dfaf2033 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,10 +1,69 @@ /* - * This is a manifest file that'll be compiled into application.css. - * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. + * Estilos básicos de formulário */ + +label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="url"], +input[type="number"], +input[type="search"], +input[type="tel"], +select, +textarea { + display: block; + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid #9ca3af; + border-radius: 0.375rem; + background-color: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + font-size: 1rem; + line-height: 1.5; + color: #111827; +} + +select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); + background-position: right 0.6rem center; + background-repeat: no-repeat; + background-size: 1.25rem; + padding-right: 2.25rem; +} + +input[type="file"] { + display: block; + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid #9ca3af; + border-radius: 0.375rem; + background-color: #fff; + font-size: 0.875rem; + color: #374151; +} + +input:focus, +select:focus, +textarea:focus { + outline: 2px solid #2563eb; + outline-offset: 0; + border-color: #2563eb; +} + +input:disabled, +select:disabled, +textarea:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 000000000..d67269728 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..d4cd25972 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,19 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + private + + def find_verified_user + session_id = cookies.signed[:session_id] + session = Session.find_by(id: session_id) if session_id + return session.user if session + + reject_unauthorized_connection + end + end +end diff --git a/app/channels/dashboard_channel.rb b/app/channels/dashboard_channel.rb new file mode 100644 index 000000000..6abc89c4b --- /dev/null +++ b/app/channels/dashboard_channel.rb @@ -0,0 +1,11 @@ +class DashboardChannel < ApplicationCable::Channel + def subscribed + reject unless current_user&.admin? + stream_from "dashboard_stats" + transmit({ type: "stats", **User.dashboard_stats }) + end + + def self.broadcast_stats + ActionCable.server.broadcast("dashboard_stats", { type: "stats", **User.dashboard_stats }) + end +end diff --git a/app/channels/import_channel.rb b/app/channels/import_channel.rb new file mode 100644 index 000000000..0e0cd0368 --- /dev/null +++ b/app/channels/import_channel.rb @@ -0,0 +1,7 @@ +class ImportChannel < ApplicationCable::Channel + def subscribed + import = Import.find(params[:id]) + reject unless current_user&.admin? || import.user_id == current_user&.id + stream_for import + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..0e099c706 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,5 @@ +module Admin + class BaseController < ApplicationController + before_action :require_admin + end +end diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb new file mode 100644 index 000000000..f9f968fdf --- /dev/null +++ b/app/controllers/admin/dashboards_controller.rb @@ -0,0 +1,8 @@ +module Admin + class DashboardsController < BaseController + def show + @stats = User.dashboard_stats + @recent_imports = Import.order(created_at: :desc).limit(5) + end + end +end diff --git a/app/controllers/admin/imports_controller.rb b/app/controllers/admin/imports_controller.rb new file mode 100644 index 000000000..78c8b97ea --- /dev/null +++ b/app/controllers/admin/imports_controller.rb @@ -0,0 +1,38 @@ +module Admin + class ImportsController < BaseController + def index + @imports = Import.includes(:user).order(created_at: :desc) + end + + def show + @import = Import.find(params[:id]) + end + + def new + @import = Import.new + end + + def create + file = params.dig(:import, :spreadsheet) + unless file.present? + @import = Import.new + flash.now[:alert] = "Selecione um arquivo .csv ou .xlsx." + render :new, status: :unprocessable_entity + return + end + + @import = current_user.imports.build( + filename: file.original_filename, + status: "pending" + ) + @import.spreadsheet.attach(file) + + if @import.save + ProcessImportJob.perform_later(@import.id) + redirect_to admin_import_path(@import), notice: "Importação enfileirada. O progresso atualiza em tempo real abaixo." + else + render :new, status: :unprocessable_entity + end + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..ef19a1f30 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,82 @@ +module Admin + class UsersController < BaseController + before_action :set_user, only: %i[show edit update destroy toggle_role] + + def index + @users = User.includes(:user_role, avatar_image_attachment: :blob) + .where.not(id: current_user.id) + .order(:full_name) + end + + def show + end + + def new + @user = User.new(user_role: UserRole.non_admin) + @roles = UserRole.order(:label) + end + + def create + @user = User.new(user_params) + @roles = UserRole.order(:label) + + if @user.save + redirect_to admin_user_path(@user), notice: "Usuário criado." + else + render :new, status: :unprocessable_entity + end + end + + def edit + @roles = UserRole.order(:label) + end + + def update + @roles = UserRole.order(:label) + + if @user.update(user_params) + redirect_to admin_user_path(@user), notice: "Usuário atualizado." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + if @user == current_user + redirect_to admin_users_path, alert: "Você não pode excluir a própria conta de admin aqui." + return + end + + @user.destroy! + redirect_to admin_users_path, notice: "Usuário excluído.", status: :see_other + end + + def toggle_role + if @user == current_user + redirect_to admin_users_path, alert: "Você não pode alterar o próprio perfil." + return + end + + @user.toggle_role! + redirect_to admin_users_path, notice: "#{@user.full_name} agora é #{@user.user_role.label}." + end + + private + + def set_user + @user = User.find(params[:id]) + end + + def user_params + params.require(:user).permit( + :full_name, + :email, + :password, + :password_confirmation, + :user_role_id, + :avatar_url, + :avatar_image + ) + end + end +end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb new file mode 100644 index 000000000..5545968eb --- /dev/null +++ b/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,2 @@ +class Api::V1::BaseController < ActionController::API +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 000000000..b953f69e0 --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,42 @@ +class Api::V1::UsersController < Api::V1::BaseController + def index + users = User.all + render json: UserSerializer.new(users).serializable_hash + end + + def show + user = User.find(params[:id]) + render json: UserSerializer.new(user).serializable_hash + end + + def create + user = User.new(user_params) + user.user_role ||= UserRole.non_admin + if user.save + render json: UserSerializer.new(user).serializable_hash, status: :created + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + + def update + user = User.find(params[:id]) + if user.update(user_params) + render json: UserSerializer.new(user).serializable_hash + else + render json: { errors: user.errors.full_messages }, status: :unprocessable_entity + end + end + + def destroy + user = User.find(params[:id]) + user.destroy + head :no_content + end + + private + + def user_params + params.require(:user).permit(:full_name, :email, :password, :password_confirmation, :avatar_url, :avatar_image) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c3537563d..e70b3c2a8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,11 @@ class ApplicationController < ActionController::Base + include Authentication + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + helper_method :current_user end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..6e441cb0e --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,71 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated?, :current_user + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + + def current_user + Current.user + end + + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || default_authenticated_url + end + + def default_authenticated_url + if Current.user&.admin? + admin_dashboard_url + else + profile_url + end + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end + + def require_admin + return if current_user&.admin? + + redirect_to profile_path, alert: "Você não tem permissão para acessar essa área." + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 000000000..408d88fa5 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,11 @@ +class HomeController < ApplicationController + allow_unauthenticated_access only: :index + + def index + if authenticated? + redirect_to(current_user.admin? ? admin_dashboard_path : profile_path) + else + redirect_to new_session_path + end + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..e0a4f44e3 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,36 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[edit update] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Tente novamente mais tarde." } + + def new + end + + def create + if (user = User.find_by(email: params[:email])) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Se o e-mail existir, enviamos as instruções de redefinição." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Senha redefinida com sucesso." + else + redirect_to edit_password_path(params[:token]), alert: "As senhas não conferem." + end + end + + private + + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "O link de redefinição é inválido ou expirou." + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..0f79617ca --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,40 @@ +class ProfilesController < ApplicationController + before_action :set_user + + def show + end + + def edit + end + + def update + if @user.update(profile_params) + redirect_to profile_path, notice: "Perfil atualizado." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + terminate_session + @user.destroy! + redirect_to new_session_path, notice: "Sua conta foi excluída.", status: :see_other + end + + private + + def set_user + @user = current_user + end + + def profile_params + params.require(:user).permit( + :full_name, + :email, + :password, + :password_confirmation, + :avatar_url, + :avatar_image + ) + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..651d638ab --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,32 @@ +class RegistrationsController < ApplicationController + allow_unauthenticated_access + + def new + @user = User.new + end + + def create + @user = User.new(registration_params) + @user.user_role = UserRole.non_admin + + if @user.save + start_new_session_for @user + redirect_to profile_path, notice: "Conta criada com sucesso. Bem-vindo!" + else + render :new, status: :unprocessable_entity + end + end + + private + + def registration_params + params.require(:user).permit( + :full_name, + :email, + :password, + :password_confirmation, + :avatar_url, + :avatar_image + ) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..93a4371ec --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,22 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[new create] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Tente novamente mais tarde." } + + def new + redirect_to(default_authenticated_url) if authenticated? + end + + def create + if (user = User.authenticate_by(email: params[:email], password: params[:password])) + start_new_session_for user + redirect_to after_authentication_url, notice: "Login realizado com sucesso." + else + redirect_to new_session_path, alert: "E-mail ou senha inválidos." + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other, notice: "Você saiu da conta." + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be7945..53d1d427e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,10 @@ module ApplicationHelper + def import_status_label(status) + { + "pending" => "pendente", + "processing" => "processando", + "completed" => "concluída", + "failed" => "falhou" + }.fetch(status.to_s, status.to_s) + end end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 000000000..0d7b49404 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 000000000..1213e85c7 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/avatar_preview_controller.js b/app/javascript/controllers/avatar_preview_controller.js new file mode 100644 index 000000000..aeaebab96 --- /dev/null +++ b/app/javascript/controllers/avatar_preview_controller.js @@ -0,0 +1,28 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["url", "preview", "placeholder"] + + connect() { + this.refresh() + } + + refresh() { + const url = this.urlTarget.value.trim() + if (!url) { + this.previewTarget.classList.add("hidden") + this.previewTarget.removeAttribute("src") + this.placeholderTarget.classList.remove("hidden") + return + } + + this.previewTarget.src = url + this.previewTarget.classList.remove("hidden") + this.placeholderTarget.classList.add("hidden") + } + + onError() { + this.previewTarget.classList.add("hidden") + this.placeholderTarget.classList.remove("hidden") + } +} diff --git a/app/javascript/controllers/dashboard_controller.js b/app/javascript/controllers/dashboard_controller.js new file mode 100644 index 000000000..f1e2145aa --- /dev/null +++ b/app/javascript/controllers/dashboard_controller.js @@ -0,0 +1,30 @@ +import { Controller } from "@hotwired/stimulus" +import { createConsumer } from "@rails/actioncable" + +export default class extends Controller { + static targets = ["total", "admins", "nonAdmins"] + static values = { + total: Number, + admins: Number, + nonAdmins: Number + } + + connect() { + this.consumer = createConsumer() + this.subscription = this.consumer.subscriptions.create("DashboardChannel", { + received: (data) => this.updateStats(data) + }) + } + + disconnect() { + this.subscription?.unsubscribe() + this.consumer?.disconnect() + } + + updateStats(data) { + if (data.type !== "stats") return + this.totalTarget.textContent = data.total + this.adminsTarget.textContent = data.admins + this.nonAdminsTarget.textContent = data.non_admins + } +} diff --git a/app/javascript/controllers/form_validation_controller.js b/app/javascript/controllers/form_validation_controller.js new file mode 100644 index 000000000..4979cf0e2 --- /dev/null +++ b/app/javascript/controllers/form_validation_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "fullName", "fullNameError", + "email", "emailError", + "password", "passwordConfirmation", "passwordError" + ] + + validate() { + this.toggle(this.fullNameErrorTarget, this.fullNameTarget.value.trim().length === 0) + this.toggle(this.emailErrorTarget, !this.validEmail(this.emailTarget.value)) + + const password = this.passwordTarget.value + const confirmation = this.passwordConfirmationTarget.value + const passwordInvalid = password.length > 0 && (password.length < 8 || password !== confirmation) + this.toggle(this.passwordErrorTarget, passwordInvalid) + } + + validEmail(value) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + } + + toggle(el, show) { + el.classList.toggle("hidden", !show) + } +} diff --git a/app/javascript/controllers/import_progress_controller.js b/app/javascript/controllers/import_progress_controller.js new file mode 100644 index 000000000..970d11207 --- /dev/null +++ b/app/javascript/controllers/import_progress_controller.js @@ -0,0 +1,48 @@ +import { Controller } from "@hotwired/stimulus" +import { createConsumer } from "@rails/actioncable" + +export default class extends Controller { + static targets = ["status", "percentLabel", "bar", "processed", "success", "failure", "error"] + static values = { + id: Number, + status: String, + percent: Number, + processed: Number, + total: Number, + success: Number, + failure: Number + } + + connect() { + this.consumer = createConsumer() + this.subscription = this.consumer.subscriptions.create( + { channel: "ImportChannel", id: this.idValue }, + { received: (data) => this.render(data) } + ) + } + + disconnect() { + this.subscription?.unsubscribe() + this.consumer?.disconnect() + } + + render(data) { + const labels = { + pending: "pendente", + processing: "processando", + completed: "concluída", + failed: "falhou" + } + this.statusTarget.textContent = labels[data.status] || data.status + this.percentLabelTarget.textContent = `${data.progress_percent}%` + this.barTarget.style.width = `${data.progress_percent}%` + this.processedTarget.textContent = `${data.processed_rows}/${data.total_rows}` + this.successTarget.textContent = data.success_count + this.failureTarget.textContent = data.failure_count + + if (data.error_message) { + this.errorTarget.textContent = data.error_message + this.errorTarget.classList.remove("hidden") + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 000000000..1156bf836 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/jobs/process_import_job.rb b/app/jobs/process_import_job.rb new file mode 100644 index 000000000..a5f96447a --- /dev/null +++ b/app/jobs/process_import_job.rb @@ -0,0 +1,8 @@ +class ProcessImportJob < ApplicationJob + queue_as :default + + def perform(import_id) + import = Import.find(import_id) + SpreadsheetUserImporter.new(import).call + end +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 000000000..18b63a45e --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Redefinir sua senha", to: user.email + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..2bef56dad --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/import.rb b/app/models/import.rb new file mode 100644 index 000000000..38bd36bf3 --- /dev/null +++ b/app/models/import.rb @@ -0,0 +1,52 @@ +class Import < ApplicationRecord + belongs_to :user + has_one_attached :spreadsheet + + STATUSES = %w[pending processing completed failed].freeze + + validates :filename, presence: true + validates :status, inclusion: { in: STATUSES } + validates :spreadsheet, presence: true, on: :create + + after_commit :broadcast_progress, on: %i[create update] + + def progress_percent + return 0 if total_rows.zero? + + ((processed_rows.to_f / total_rows) * 100).round + end + + def mark_processing!(total) + update!(status: "processing", total_rows: total, processed_rows: 0, success_count: 0, failure_count: 0) + end + + def increment_progress!(success:) + attrs = { processed_rows: processed_rows + 1 } + attrs[success ? :success_count : :failure_count] = (success ? success_count : failure_count) + 1 + update!(attrs) + end + + def mark_completed! + update!(status: "completed") + end + + def mark_failed!(message) + update!(status: "failed", error_message: message) + end + + private + + def broadcast_progress + ImportChannel.broadcast_to(self, { + id: id, + status: status, + filename: filename, + total_rows: total_rows, + processed_rows: processed_rows, + success_count: success_count, + failure_count: failure_count, + progress_percent: progress_percent, + error_message: error_message + }) + end +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 000000000..cf376fb28 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..2aa12f955 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,60 @@ +class User < ApplicationRecord + belongs_to :user_role + has_many :sessions, dependent: :destroy + has_many :imports, dependent: :destroy + has_one_attached :avatar_image + + has_secure_password + generates_token_for :password_reset, expires_in: 15.minutes + + normalizes :email, with: ->(e) { e.strip.downcase } + + validates :full_name, presence: true + validates :email, presence: true, uniqueness: true, + format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, length: { minimum: 8 }, if: -> { password.present? } + validate :avatar_url_must_be_http_url, if: -> { avatar_url.present? } + + after_commit :broadcast_dashboard_stats, on: %i[create update destroy] + + scope :admins, -> { joins(:user_role).where(user_roles: { is_admin: true }) } + scope :non_admins, -> { joins(:user_role).where(user_roles: { is_admin: false }) } + + def admin? + user_role&.is_admin? + end + + def avatar_display_url + if avatar_image.attached? + Rails.application.routes.url_helpers.rails_blob_path(avatar_image, only_path: true) + elsif avatar_url.present? + avatar_url + end + end + + def toggle_role! + target = admin? ? UserRole.non_admin : UserRole.admin + update!(user_role: target) + end + + def self.dashboard_stats + { + total: count, + admins: admins.count, + non_admins: non_admins.count + } + end + + private + + def avatar_url_must_be_http_url + uri = URI.parse(avatar_url) + errors.add(:avatar_url, :invalid_url) unless uri.is_a?(URI::HTTP) && uri.host.present? + rescue URI::InvalidURIError + errors.add(:avatar_url, :invalid_url) + end + + def broadcast_dashboard_stats + DashboardChannel.broadcast_stats + end +end diff --git a/app/models/user_role.rb b/app/models/user_role.rb new file mode 100644 index 000000000..1b20a185b --- /dev/null +++ b/app/models/user_role.rb @@ -0,0 +1,17 @@ +class UserRole < ApplicationRecord + has_many :users, dependent: :restrict_with_exception + + validates :label, presence: true, uniqueness: true + validates :is_admin, inclusion: { in: [ true, false ] } + + scope :admin_roles, -> { where(is_admin: true) } + scope :non_admin_roles, -> { where(is_admin: false) } + + def self.admin + find_by!(is_admin: true) + end + + def self.non_admin + find_by!(is_admin: false) + end +end diff --git a/app/serializers/user_role_serializer.rb b/app/serializers/user_role_serializer.rb new file mode 100644 index 000000000..88413c7f5 --- /dev/null +++ b/app/serializers/user_role_serializer.rb @@ -0,0 +1,5 @@ +class UserRoleSerializer + include FastJsonapi::ObjectSerializer + attributes :label, :is_admin + has_many :users +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb new file mode 100644 index 000000000..8ba7a79f2 --- /dev/null +++ b/app/serializers/user_serializer.rb @@ -0,0 +1,21 @@ +class UserSerializer + include FastJsonapi::ObjectSerializer + + attributes :full_name, :email, :avatar_url + + attribute :user_role do |user| + next nil unless user.user_role + + { + id: user.user_role.id, + label: user.user_role.label, + is_admin: user.user_role.is_admin + } + end + + attribute :avatar_image_url do |user| + user.avatar_display_url + end + + belongs_to :user_role +end diff --git a/app/services/spreadsheet_user_importer.rb b/app/services/spreadsheet_user_importer.rb new file mode 100644 index 000000000..18b55cc49 --- /dev/null +++ b/app/services/spreadsheet_user_importer.rb @@ -0,0 +1,93 @@ +require "csv" +require "roo" + +class SpreadsheetUserImporter + REQUIRED_HEADERS = %w[full_name email].freeze + + def initialize(import) + @import = import + end + + def call + rows = parse_rows + @import.mark_processing!(rows.size) + + rows.each do |row| + create_user_from_row(row) + end + + @import.mark_completed! + DashboardChannel.broadcast_stats + rescue StandardError => e + @import.mark_failed!(e.message) + raise + end + + private + + def parse_rows + blob = @import.spreadsheet.download + filename = @import.filename.to_s.downcase + + if filename.end_with?(".xlsx", ".xls") + parse_xlsx(blob) + else + parse_csv(blob) + end + end + + def parse_csv(blob) + table = CSV.parse(blob, headers: true) + validate_headers!(table.headers) + table.map { |row| row.to_h.transform_keys { |k| k.to_s.strip.downcase } } + end + + def parse_xlsx(blob) + Tempfile.create([ "import", File.extname(@import.filename) ]) do |tmp| + tmp.binmode + tmp.write(blob) + tmp.flush + + sheet = Roo::Spreadsheet.open(tmp.path) + headers = sheet.row(1).map { |h| h.to_s.strip.downcase } + validate_headers!(headers) + + (2..sheet.last_row).map do |i| + values = sheet.row(i) + headers.zip(values).to_h + end + end + end + + def validate_headers!(headers) + normalized = Array(headers).map { |h| h.to_s.strip.downcase } + missing = REQUIRED_HEADERS - normalized + raise ArgumentError, "Colunas obrigatórias ausentes: #{missing.join(', ')}" if missing.any? + end + + def create_user_from_row(row) + password = row["password"].presence || SecureRandom.alphanumeric(12) + role = if row["role"].to_s.downcase.in?(%w[admin true yes]) + UserRole.admin + else + UserRole.non_admin + end + + user = User.new( + full_name: row["full_name"], + email: row["email"], + password: password, + password_confirmation: password, + avatar_url: row["avatar_url"].presence, + user_role: role + ) + + if user.save + @import.increment_progress!(success: true) + else + errors = (@import.row_errors || []) + [ { email: row["email"], errors: user.errors.full_messages } ] + @import.update!(row_errors: errors) + @import.increment_progress!(success: false) + end + end +end diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb new file mode 100644 index 000000000..b962def33 --- /dev/null +++ b/app/views/admin/dashboards/show.html.erb @@ -0,0 +1,52 @@ +

+ +

Painel

+ +
+
+
Total de usuários
+
<%= @stats[:total] %>
+
+
+
Admins
+
<%= @stats[:admins] %>
+
+
+
Não-admins
+
<%= @stats[:non_admins] %>
+
+
+ +
+ <%= link_to "Gerenciar usuários", admin_users_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white font-medium" %> + <%= link_to "Importar planilha", new_admin_import_path, class: "rounded-lg py-3 px-5 bg-gray-100 font-medium" %> +
+ +

Importações recentes

+ <% if @recent_imports.any? %> + + + + + + + + + + <% @recent_imports.each do |import| %> + + + + + + <% end %> + +
ArquivoStatusProgresso
<%= link_to import.filename, admin_import_path(import) %><%= import_status_label(import.status) %><%= import.processed_rows %>/<%= import.total_rows %>
+ <% else %> +

Nenhuma importação ainda.

+ <% end %> +
diff --git a/app/views/admin/imports/index.html.erb b/app/views/admin/imports/index.html.erb new file mode 100644 index 000000000..8aa76adad --- /dev/null +++ b/app/views/admin/imports/index.html.erb @@ -0,0 +1,33 @@ +
+
+

Importações

+ <%= link_to "Nova importação", new_admin_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> +
+ + <% if @imports.any? %> + + + + + + + + + + + + <% @imports.each do |import| %> + + + + + + + + <% end %> + +
ArquivoStatusSucessoFalhasProgresso
<%= link_to import.filename, admin_import_path(import) %><%= import_status_label(import.status) %><%= import.success_count %><%= import.failure_count %><%= import.progress_percent %>%
+ <% else %> +

Nenhuma importação ainda.

+ <% end %> +
diff --git a/app/views/admin/imports/new.html.erb b/app/views/admin/imports/new.html.erb new file mode 100644 index 000000000..35a3fe4a8 --- /dev/null +++ b/app/views/admin/imports/new.html.erb @@ -0,0 +1,35 @@ +
+

Nova importação

+

+ CSV/XLSX com colunas: full_name, email. Opcional: password, role, avatar_url. +

+

+ Baixar exemplo: + <%= link_to "usuarios_exemplo.xlsx", "/examples/usuarios_exemplo.xlsx" %> + · + <%= link_to "usuarios_exemplo.csv", "/examples/usuarios_exemplo.csv" %> +

+ + <%= form_with model: @import, url: admin_imports_path, multipart: true, class: "contents" do |form| %> + <% if @import.errors.any? %> +
+
    + <% @import.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :spreadsheet, "Planilha" %> + <%= form.file_field :spreadsheet, accept: ".csv,.xlsx,.xls", required: true %> +
+ + <%= form.submit "Iniciar importação", class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> + <% end %> + +
+ <%= link_to "Voltar às importações", admin_imports_path %> +
+
diff --git a/app/views/admin/imports/show.html.erb b/app/views/admin/imports/show.html.erb new file mode 100644 index 000000000..08456bd41 --- /dev/null +++ b/app/views/admin/imports/show.html.erb @@ -0,0 +1,38 @@ +
+ +

Importação #<%= @import.id %>

+

<%= @import.filename %>

+ +
+

+ Status: + <%= import_status_label(@import.status) %> + (<%= @import.progress_percent %>%) +

+ +
+
+
+ +

+ Processados: + <%= @import.processed_rows %>/<%= @import.total_rows %> +

+

+ Sucesso: + <%= @import.success_count %> +

+

+ Falhas: + <%= @import.failure_count %> +

+ +

+ <%= @import.error_message %> +

+
+ + <%= link_to "Voltar às importações", admin_imports_path %> +
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 000000000..72dc73d4d --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,10 @@ +
+

Editar usuário

+ + <%= render "shared/user_form", user: @user, url: admin_user_path(@user), method: :patch, submit_label: "Atualizar usuário", show_role: true, roles: @roles %> + +
+ <%= link_to "Ver", admin_user_path(@user) %> | + <%= link_to "Voltar aos usuários", admin_users_path %> +
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 000000000..b8163376b --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,38 @@ +
+
+

Usuários

+ <%= link_to "Novo usuário", new_admin_user_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> +
+ + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + <% end %> + +
NomeE-mailPerfil
+
+ <%= render "shared/avatar", user: user %> + <%= link_to user.full_name, admin_user_path(user) %> +
+
<%= user.email %><%= user.user_role.label %> + <%= link_to "Editar", edit_admin_user_path(user) %> + | + <%= button_to "Alternar perfil", toggle_role_admin_user_path(user), method: :patch, class: "inline underline cursor-pointer bg-transparent border-0 p-0" %> + | + <%= button_to "Excluir", admin_user_path(user), method: :delete, form: { data: { turbo_confirm: "Tem certeza?" }, class: "inline" }, class: "inline underline cursor-pointer bg-transparent border-0 p-0 text-red-600" %> +
+
diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 000000000..a8f540813 --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,9 @@ +
+

Novo usuário

+ + <%= render "shared/user_form", user: @user, url: admin_users_path, submit_label: "Criar usuário", show_role: true, roles: @roles %> + +
+ <%= link_to "Voltar aos usuários", admin_users_path %> +
+
diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb new file mode 100644 index 000000000..b34f1e89c --- /dev/null +++ b/app/views/admin/users/show.html.erb @@ -0,0 +1,17 @@ +
+

<%= @user.full_name %>

+ +
+ <%= render "shared/avatar", user: @user %> +
+

E-mail: <%= @user.email %>

+

Perfil: <%= @user.user_role.label %>

+
+
+ +
+ <%= link_to "Editar", edit_admin_user_path(@user), class: "rounded-lg py-3 px-5 bg-gray-100 font-medium" %> + <%= button_to "Alternar perfil", toggle_role_admin_user_path(@user), method: :patch, class: "rounded-lg py-3 px-5 bg-gray-100 font-medium cursor-pointer" %> + <%= link_to "Voltar aos usuários", admin_users_path, class: "rounded-lg py-3 px-5 bg-gray-100 font-medium" %> +
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5dc5c973f..41f090e25 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,28 +1,47 @@ - + - <%= content_for(:title) || "Fullstack Vanilla" %> + <%= content_for(:title) || "Umanni Usuários" %> - - - <%= csrf_meta_tags %> <%= csp_meta_tag %> - + <%= action_cable_meta_tag %> <%= yield :head %> - - <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> - <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> - - - - - - <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> - - <%= yield %> + + <% if authenticated? %> +
+
+
+ <%= link_to "Umanni Usuários", root_path %> + <% if current_user.admin? %> + <%= link_to "Painel", admin_dashboard_path %> + <%= link_to "Usuários", admin_users_path %> + <%= link_to "Importações", admin_imports_path %> + <% end %> + <%= link_to "Perfil", profile_path %> +
+
+ <%= current_user.email %> + <%= button_to "Sair", session_path, method: :delete, class: "underline cursor-pointer bg-transparent border-0 p-0 text-sm" %> +
+
+
+ <% end %> + +
+ <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + + <%= yield %> +
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 000000000..cafc53331 --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,17 @@ +
+

Nova senha

+ + <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> +
+ <%= form.label :password, "Nova senha" %> + <%= form.password_field :password, required: true, autocomplete: "new-password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :password_confirmation, "Confirmar senha" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ + <%= form.submit "Salvar", class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> + <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 000000000..8c7733005 --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,16 @@ +
+

Recuperar senha

+ + <%= form_with url: passwords_path, class: "contents" do |form| %> +
+ <%= form.label :email, "E-mail" %> + <%= form.email_field :email, required: true, autofocus: true, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ + <%= form.submit "Enviar instruções", class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> + <% end %> + +
+ <%= link_to "Voltar para o login", new_session_path %> +
+
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 000000000..ec8998ba9 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,5 @@ +

+ Redefina sua senha nesta + <%= link_to "página de redefinição", edit_password_url(@user.generate_token_for(:password_reset)) %>. + O link expira em 15 minutos. +

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 000000000..3c991d943 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,2 @@ +Redefina sua senha em até 15 minutos: +<%= edit_password_url(@user.generate_token_for(:password_reset)) %> diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 000000000..29a6303aa --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,9 @@ +
+

Editar perfil

+ + <%= render "shared/user_form", user: @user, url: profile_path, method: :patch, submit_label: "Salvar", show_role: false %> + +
+ <%= link_to "Voltar", profile_path %> +
+
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 000000000..16aaccd4c --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,18 @@ +
+
+

Perfil

+
+ <%= link_to "Editar", edit_profile_path, class: "rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %> + <%= button_to "Excluir", profile_path, method: :delete, form: { data: { turbo_confirm: "Tem certeza?" } }, class: "rounded-lg py-3 px-5 bg-red-100 text-red-700 font-medium cursor-pointer" %> +
+
+ +
+ <%= render "shared/avatar", user: @user %> +
+

Nome: <%= @user.full_name %>

+

E-mail: <%= @user.email %>

+

Perfil: <%= @user.user_role.label %>

+
+
+
diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index 1e9bc8f96..a45bbdca6 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -1,5 +1,5 @@ { - "name": "FullstackVanilla", + "name": "UmanniRb", "icons": [ { "src": "/icon.png", @@ -16,7 +16,7 @@ "start_url": "/", "display": "standalone", "scope": "/", - "description": "FullstackVanilla.", + "description": "UmanniRb.", "theme_color": "red", "background_color": "red" } diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 000000000..2c45df151 --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,9 @@ +
+

Criar conta

+ + <%= render "shared/user_form", user: @user, url: registration_path, submit_label: "Criar conta", show_role: false %> + +
+ <%= link_to "Já tem conta? Entrar", new_session_path %> +
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 000000000..d899f047c --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,25 @@ +
+

Entrar

+ + <%= form_with url: session_path, class: "contents" do |form| %> +
+ <%= form.label :email, "E-mail" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", value: params[:email], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :password, "Senha" %> + <%= form.password_field :password, required: true, autocomplete: "current-password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Entrar", class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> + +

+ <%= link_to "Esqueci a senha", new_password_path %> + · + <%= link_to "Criar conta", new_registration_path %> +

+
diff --git a/app/views/shared/_avatar.html.erb b/app/views/shared/_avatar.html.erb new file mode 100644 index 000000000..7c2a4277e --- /dev/null +++ b/app/views/shared/_avatar.html.erb @@ -0,0 +1,6 @@ +<% url = user.avatar_display_url %> +<% if url.present? %> + <%= image_tag url, alt: user.full_name, width: 40, height: 40, style: "border-radius: 50%; object-fit: cover;" %> +<% else %> + <%= user.full_name.to_s[0]&.upcase %> +<% end %> diff --git a/app/views/shared/_user_form.html.erb b/app/views/shared/_user_form.html.erb new file mode 100644 index 000000000..9fa7c1bef --- /dev/null +++ b/app/views/shared/_user_form.html.erb @@ -0,0 +1,86 @@ +<%# locals: (user:, url:, submit_label:, show_role: false, roles: [], method: :post) %> +<%= form_with model: user, url: url, method: local_assigns.fetch(:method, :post), multipart: true, class: "contents", data: { controller: "form-validation" } do |form| %> + <% if user.errors.any? %> +
+

<%= pluralize(user.errors.count, "erro") %> impediram o salvamento:

+
    + <% user.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :full_name, "Nome completo" %> + <%= form.text_field :full_name, required: true, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full", data: { form_validation_target: "fullName", action: "input->form-validation#validate" } %> + +
+ +
+ <%= form.label :email, "E-mail" %> + <%= form.email_field :email, required: true, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full", data: { form_validation_target: "email", action: "input->form-validation#validate" } %> + +
+ +
+ <%= form.label :password, "Senha" %> + <%= form.password_field :password, autocomplete: "new-password", maxlength: 72, required: user.new_record?, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full", data: { form_validation_target: "password", action: "input->form-validation#validate" } %> +
<%= user.new_record? ? "Mínimo de 8 caracteres" : "Deixe em branco para manter a senha atual" %>
+
+ +
+ <%= form.label :password_confirmation, "Confirmação de senha" %> + <%= form.password_field :password_confirmation, autocomplete: "new-password", maxlength: 72, required: user.new_record?, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full", data: { form_validation_target: "passwordConfirmation", action: "input->form-validation#validate" } %> + +
+ +
+ <%= form.label :avatar_url, "URL pública da foto" %> +
+ <% if user.avatar_url.present? %> + Prévia do avatar + + <% else %> + + + <%= user.full_name.to_s[0]&.upcase %> + + <% end %> +
+ <%= form.url_field :avatar_url, + class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 w-full", + data: { avatar_preview_target: "url", action: "input->avatar-preview#refresh change->avatar-preview#refresh" } %> +
+ +
+ <%= form.label :avatar_image, "Arquivo da foto" %> + <%= form.file_field :avatar_image, accept: "image/*" %> +
+ + <% if show_role %> +
+ <%= form.label :user_role_id, "Perfil" %> + <%= form.collection_select :user_role_id, roles, :id, :label, {}, class: "mt-2" %> +
+ <% end %> + +
+ <%= form.submit submit_label, class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %> +
+<% end %> diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index ed31659f4..283112b73 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,5 +1,8 @@ #!/bin/bash -e +# Ensure storage dirs exist and are writable (for first run without volume) +mkdir -p /rails/storage /rails/tmp/pids /rails/log + # If running the rails server then create or migrate existing database if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then ./bin/rails db:prepare diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 000000000..36502ab16 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/config/application.rb b/config/application.rb index 4ebdc1307..e4d40d4d2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,24 +1,12 @@ require_relative "boot" -require "rails" -# Pick the frameworks you want: -require "active_model/railtie" -require "active_job/railtie" -require "active_record/railtie" -require "active_storage/engine" -require "action_controller/railtie" -require "action_mailer/railtie" -require "action_mailbox/engine" -require "action_text/engine" -require "action_view/railtie" -require "action_cable/engine" -# require "rails/test_unit/railtie" +require "rails/all" # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) -module FullstackVanilla +module UmanniRb class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 8.1 @@ -35,8 +23,7 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") - - # Don't generate system test files. - config.generators.system_tests = nil + config.i18n.default_locale = :pt + config.i18n.available_locales = [ :pt, :en ] end end diff --git a/config/cable.yml b/config/cable.yml index 4fd1761fd..b9adc5aa3 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,3 +1,7 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. development: adapter: async @@ -5,6 +9,9 @@ test: adapter: test production: - adapter: redis - url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> - channel_prefix: fullstack_vanilla_production + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 000000000..19d490843 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/database.yml b/config/database.yml index 302d638c9..19a52325e 100644 --- a/config/database.yml +++ b/config/database.yml @@ -8,6 +8,10 @@ default: &default adapter: sqlite3 max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 + # WAL is required for concurrent readers (Puma + Solid Queue/Cache/Cable) on the Kamal volume. + pragmas: + journal_mode: wal + busy_timeout: 5000 development: <<: *default diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 000000000..2e8e4baaf --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,8 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" +pin "@rails/actioncable", to: "@rails--actioncable.js" # @7.2.302 diff --git a/config/initializers/frontend.rb b/config/initializers/frontend.rb new file mode 100644 index 000000000..d981d15cf --- /dev/null +++ b/config/initializers/frontend.rb @@ -0,0 +1,2 @@ +Rails.application.config.x.frontend = ActiveSupport::OrderedOptions.new +Rails.application.config.x.frontend.stack = "hotwire" diff --git a/config/initializers/json_patch.rb b/config/initializers/json_patch.rb new file mode 100644 index 000000000..97fff60b4 --- /dev/null +++ b/config/initializers/json_patch.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Patch para Ruby 4.0 + json 3.0.2 +# ActiveSupport 8.1.3.1 chama ::JSON.parse(json, options) com Hash posicional, +# mas json 3.0.2 mudou para kwargs: def parse(source, on_load: nil, ..., **options) +# Isso causava ArgumentError (given 2, expected 1) -> ParseError 400 em toda requisição JSON. +# Veja: active_support/json/decoding.rb:25 vs json/common.rb:296 + +module ActiveSupport + module JSON + class << self + def decode(json, options = {}) + data = ::JSON.parse(json, **options) + if ActiveSupport.parse_json_times + send(:convert_dates_from, data) + else + data + end + end + alias_method :load, :decode + end + end +end diff --git a/config/locales/pt.yml b/config/locales/pt.yml new file mode 100644 index 000000000..fc87b3b23 --- /dev/null +++ b/config/locales/pt.yml @@ -0,0 +1,132 @@ +pt: + activerecord: + models: + user: + one: Usuário + other: Usuários + user_role: + one: Perfil + other: Perfis + import: + one: Importação + other: Importações + attributes: + user: + full_name: Nome completo + email: E-mail + password: Senha + password_confirmation: Confirmação de senha + password_digest: Senha + avatar_url: URL pública da foto + avatar_image: Arquivo da foto + user_role: Perfil + user_role_id: Perfil + created_at: Criado em + updated_at: Atualizado em + import: + filename: Arquivo + spreadsheet: Planilha + status: Status + total_rows: Total de linhas + processed_rows: Linhas processadas + success_count: Sucessos + failure_count: Falhas + error_message: Mensagem de erro + created_at: Criado em + updated_at: Atualizado em + user_role: + label: Nome + is_admin: Administrador + errors: + models: + user: + attributes: + avatar_url: + invalid_url: deve ser uma URL HTTP(S) válida + email: + taken: já está em uso + invalid: não é um e-mail válido + password: + too_short: é muito curta (mínimo %{count} caracteres) + password_confirmation: + confirmation: não confere com a senha + full_name: + blank: não pode ficar em branco + errors: + format: "%{attribute} %{message}" + messages: + blank: não pode ficar em branco + invalid: é inválido + taken: já está em uso + confirmation: não confere + too_short: é muito curta (mínimo %{count} caracteres) + required: é obrigatório + not_a_number: não é um número + helpers: + submit: + user: + create: Criar usuário + update: Salvar + create: Criar + update: Salvar + submit: Enviar + label: + user: + full_name: Nome completo + email: E-mail + password: Senha + password_confirmation: Confirmação de senha + avatar_url: URL pública da foto + avatar_image: Arquivo da foto + user_role_id: Perfil + import: + spreadsheet: Planilha + email: E-mail + password: Senha + password_confirmation: Confirmação de senha + support: + array: + words_connector: ", " + two_words_connector: " e " + last_word_connector: " e " + datetime: + distance_in_words: + half_a_minute: meio minuto + less_than_x_seconds: + one: menos de 1 segundo + other: menos de %{count} segundos + x_seconds: + one: 1 segundo + other: "%{count} segundos" + less_than_x_minutes: + one: menos de um minuto + other: menos de %{count} minutos + x_minutes: + one: 1 minuto + other: "%{count} minutos" + about_x_hours: + one: aproximadamente 1 hora + other: aproximadamente %{count} horas + x_days: + one: 1 dia + other: "%{count} dias" + about_x_months: + one: aproximadamente 1 mês + other: aproximadamente %{count} meses + x_months: + one: 1 mês + other: "%{count} meses" + about_x_years: + one: aproximadamente 1 ano + other: aproximadamente %{count} anos + over_x_years: + one: mais de 1 ano + other: mais de %{count} anos + almost_x_years: + one: quase 1 ano + other: quase %{count} anos + number: + format: + separator: "," + delimiter: "." + precision: 3 diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 000000000..6b1436086 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/routes.rb b/config/routes.rb index 48254e88e..2ad456ff9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,14 +1,26 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resource :session, only: %i[new create destroy] + resources :passwords, param: :token, only: %i[new create edit update] + resource :registration, only: %i[new create] + resource :profile, only: %i[show edit update destroy] - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", as: :rails_health_check + namespace :admin do + resource :dashboard, only: :show + resources :users do + member do + patch :toggle_role + end + end + resources :imports, only: %i[index new create show] + end + + namespace :api do + namespace :v1 do + resources :users, only: %i[index show create update destroy] + end + end - # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) - # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest - # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + root "home#index" - # Defines the root path route ("/") - # root "posts#index" + get "up" => "rails/health#show", as: :rails_health_check end diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 000000000..23666604a --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 000000000..81a410d18 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20260909123106_create_user_roles.rb b/db/migrate/20260909123106_create_user_roles.rb new file mode 100644 index 000000000..6817c8b7e --- /dev/null +++ b/db/migrate/20260909123106_create_user_roles.rb @@ -0,0 +1,10 @@ +class CreateUserRoles < ActiveRecord::Migration[8.1] + def change + create_table :user_roles do |t| + t.string :label + t.boolean :is_admin, default: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260909123215_create_active_storage_tables.active_storage.rb b/db/migrate/20260909123215_create_active_storage_tables.active_storage.rb new file mode 100644 index 000000000..6bd8bd082 --- /dev/null +++ b/db/migrate/20260909123215_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/migrate/20260910121556_create_users.rb b/db/migrate/20260910121556_create_users.rb new file mode 100644 index 000000000..37369a23f --- /dev/null +++ b/db/migrate/20260910121556_create_users.rb @@ -0,0 +1,14 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :full_name, null: false + t.string :email, null: false + t.string :password_digest, null: false + t.string :avatar_url + t.references :user_role, null: false, foreign_key: true + + t.timestamps + end + add_index :users, :email, unique: true + end +end diff --git a/db/migrate/20260910121558_create_sessions.rb b/db/migrate/20260910121558_create_sessions.rb new file mode 100644 index 000000000..ec9efdbaa --- /dev/null +++ b/db/migrate/20260910121558_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/db/migrate/20260910122000_create_imports.rb b/db/migrate/20260910122000_create_imports.rb new file mode 100644 index 000000000..335973c53 --- /dev/null +++ b/db/migrate/20260910122000_create_imports.rb @@ -0,0 +1,17 @@ +class CreateImports < ActiveRecord::Migration[8.1] + def change + create_table :imports do |t| + t.references :user, null: false, foreign_key: true + t.string :filename, null: false + t.string :status, null: false, default: "pending" + t.integer :total_rows, null: false, default: 0 + t.integer :processed_rows, null: false, default: 0 + t.integer :success_count, null: false, default: 0 + t.integer :failure_count, null: false, default: 0 + t.text :error_message + t.json :row_errors, default: [] + + t.timestamps + end + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 000000000..f9a71dabb --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,160 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "batch_id" + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.string "description" + t.text "on_finish" + t.text "on_success" + t.text "on_failure" + t.text "metadata" + t.integer "total_jobs", default: 0, null: false + t.integer "completed_jobs", default: 0, null: false + t.integer "failed_jobs", default: 0, null: false + t.datetime "enqueued_at" + t.datetime "finished_at" + t.datetime "failed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true + t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + end + + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb index 03e73681a..b9c372f92 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,5 +10,81 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 2026_09_10_122000) do + create_table "active_storage_attachments", force: :cascade do |t| + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "imports", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error_message" + t.integer "failure_count", default: 0, null: false + t.string "filename", null: false + t.integer "processed_rows", default: 0, null: false + t.json "row_errors", default: [] + t.string "status", default: "pending", null: false + t.integer "success_count", default: 0, null: false + t.integer "total_rows", default: 0, null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["user_id"], name: "index_imports_on_user_id" + end + + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "user_roles", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "is_admin", default: false + t.string "label" + t.datetime "updated_at", null: false + end + + create_table "users", force: :cascade do |t| + t.string "avatar_url" + t.datetime "created_at", null: false + t.string "email", null: false + t.string "full_name", null: false + t.string "password_digest", null: false + t.datetime "updated_at", null: false + t.integer "user_role_id", null: false + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["user_role_id"], name: "index_users_on_user_role_id" + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "imports", "users" + add_foreign_key "sessions", "users" + add_foreign_key "users", "user_roles" end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed97..0e86a838e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,24 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end +admin_role = UserRole.find_or_create_by!(label: "Admin") { |r| r.is_admin = true } +member_role = UserRole.find_or_create_by!(label: "Não-admin") { |r| r.is_admin = false } + +admin = User.find_or_initialize_by(email: "admin@example.com") +admin.assign_attributes( + full_name: "Usuário Admin", + password: "password123", + password_confirmation: "password123", + user_role: admin_role +) +admin.save! + +member = User.find_or_initialize_by(email: "user@example.com") +member.assign_attributes( + full_name: "Usuário Comum", + password: "password123", + password_confirmation: "password123", + user_role: member_role +) +member.save! + +puts "Usuários criados:" +puts " Admin -> admin@example.com / password123" +puts " Comum -> user@example.com / password123" diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 000000000..cee29fd21 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,5 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/controllers/admin/dashboards_controller_test.rb b/test/controllers/admin/dashboards_controller_test.rb new file mode 100644 index 000000000..0269448c6 --- /dev/null +++ b/test/controllers/admin/dashboards_controller_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +module Admin + class DashboardsControllerTest < ActionDispatch::IntegrationTest + test "admin can open dashboard" do + sign_in_as users(:admin) + get admin_dashboard_url + assert_response :success + assert_match(/Total de usuários/, response.body) + end + + test "member is redirected away" do + sign_in_as users(:member) + get admin_dashboard_url + assert_redirected_to profile_url + end + end +end diff --git a/test/controllers/admin/imports_controller_test.rb b/test/controllers/admin/imports_controller_test.rb new file mode 100644 index 000000000..308b89495 --- /dev/null +++ b/test/controllers/admin/imports_controller_test.rb @@ -0,0 +1,28 @@ +require "test_helper" +require "tempfile" + +module Admin + class ImportsControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + setup do + sign_in_as users(:admin) + end + + test "queues import job" do + file = Tempfile.new([ "users", ".csv" ]) + file.write("full_name,email\nJob User,job@import.test\n") + file.rewind + + assert_enqueued_with(job: ProcessImportJob) do + post admin_imports_url, params: { + import: { spreadsheet: Rack::Test::UploadedFile.new(file.path, "text/csv", original_filename: "users.csv") } + } + end + + assert_redirected_to admin_import_url(Import.last) + ensure + file.close! + end + end +end diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb new file mode 100644 index 000000000..cce71d8b2 --- /dev/null +++ b/test/controllers/admin/users_controller_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +module Admin + class UsersControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as users(:admin) + end + + test "admin can list users excluding self" do + get admin_users_url + assert_response :success + assert_match users(:member).email, response.body + assert_no_match %r{/admin/users/#{users(:admin).id}}, response.body + end + + test "admin can create user" do + assert_difference("User.count", 1) do + post admin_users_url, params: { + user: { + full_name: "Created User", + email: "created@example.com", + password: "password123", + password_confirmation: "password123", + user_role_id: user_roles(:member).id + } + } + end + assert_redirected_to admin_user_url(User.find_by!(email: "created@example.com")) + end + + test "admin can toggle role" do + user = users(:member) + assert_not user.admin? + patch toggle_role_admin_user_url(user) + assert user.reload.admin? + end + + test "non-admin cannot access admin users" do + delete session_url + sign_in_as users(:member) + get admin_users_url + assert_redirected_to profile_url + end + end +end diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb new file mode 100644 index 000000000..3423ed9b7 --- /dev/null +++ b/test/controllers/api/v1/users_controller_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest + test "lista usuarios sem autenticacao" do + get api_v1_users_url + assert_response :success + end + + test "mostra usuario sem autenticacao" do + get api_v1_user_url(users(:admin)) + assert_response :success + end + + test "cria usuario" do + assert_difference "User.count" do + post api_v1_users_url, params: { user: { full_name: "Novo User", email: "novo@example.com", password: "password123", password_confirmation: "password123" } } + end + assert_response :created + end +end diff --git a/test/controllers/profiles_controller_test.rb b/test/controllers/profiles_controller_test.rb new file mode 100644 index 000000000..04161f844 --- /dev/null +++ b/test/controllers/profiles_controller_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class ProfilesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as users(:member) + end + + test "member can view own profile" do + get profile_url + assert_response :success + assert_match users(:member).full_name, response.body + end + + test "member can update own profile" do + patch profile_url, params: { user: { full_name: "Updated Name" } } + assert_redirected_to profile_url + assert_equal "Updated Name", users(:member).reload.full_name + end + + test "member can delete own profile" do + assert_difference("User.count", -1) do + delete profile_url + end + assert_redirected_to new_session_url + end +end diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb new file mode 100644 index 000000000..61cc865ef --- /dev/null +++ b/test/controllers/registrations_controller_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class RegistrationsControllerTest < ActionDispatch::IntegrationTest + test "visitor can register as non-admin" do + assert_difference("User.count", 1) do + post registration_url, params: { + user: { + full_name: "New Visitor", + email: "visitor@example.com", + password: "password123", + password_confirmation: "password123" + } + } + end + + user = User.find_by!(email: "visitor@example.com") + assert_not user.admin? + assert_redirected_to profile_url + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 000000000..c494ba814 --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + test "admin is redirected to dashboard after login" do + post session_url, params: { email: users(:admin).email, password: "password123" } + assert_redirected_to admin_dashboard_url + end + + test "member is redirected to profile after login" do + post session_url, params: { email: users(:member).email, password: "password123" } + assert_redirected_to profile_url + end + + test "invalid credentials are rejected" do + post session_url, params: { email: users(:admin).email, password: "wrong" } + assert_redirected_to new_session_path + follow_redirect! + assert_match(/E-mail ou senha inválidos/, response.body) + end +end diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml new file mode 100644 index 000000000..4a47d72f6 --- /dev/null +++ b/test/fixtures/imports.yml @@ -0,0 +1 @@ +# Empty by default — imports are created in specific tests. diff --git a/test/fixtures/sessions.yml b/test/fixtures/sessions.yml new file mode 100644 index 000000000..e7871a9bf --- /dev/null +++ b/test/fixtures/sessions.yml @@ -0,0 +1 @@ +# Empty by default — sessions are created during request tests. diff --git a/test/fixtures/user_roles.yml b/test/fixtures/user_roles.yml new file mode 100644 index 000000000..7967d34e0 --- /dev/null +++ b/test/fixtures/user_roles.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +admin: + label: Admin + is_admin: true + +member: + label: Não-admin + is_admin: false diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 000000000..752d5e745 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,11 @@ +admin: + full_name: Admin User + email: admin@example.com + password_digest: <%= BCrypt::Password.create("password123", cost: 4) %> + user_role: admin + +member: + full_name: Regular User + email: regular@example.com + password_digest: <%= BCrypt::Password.create("password123", cost: 4) %> + user_role: member diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/import_test.rb b/test/models/import_test.rb new file mode 100644 index 000000000..bece8e1b2 --- /dev/null +++ b/test/models/import_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class ImportTest < ActiveSupport::TestCase + test "progress percent" do + import = Import.new(user: users(:admin), filename: "users.csv", total_rows: 10, processed_rows: 4) + assert_equal 40, import.progress_percent + end + + test "progress percent is zero when empty" do + import = Import.new(user: users(:admin), filename: "users.csv", total_rows: 0, processed_rows: 0) + assert_equal 0, import.progress_percent + end +end diff --git a/test/models/user_role_test.rb b/test/models/user_role_test.rb new file mode 100644 index 000000000..d3f1344f8 --- /dev/null +++ b/test/models/user_role_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class UserRoleTest < ActiveSupport::TestCase + test "admin and non_admin helpers" do + assert UserRole.admin.is_admin? + assert_not UserRole.non_admin.is_admin? + end + + test "requires unique label" do + role = UserRole.new(label: "Admin", is_admin: false) + assert_not role.valid? + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 000000000..f305007ee --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,53 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "valid user" do + user = User.new( + full_name: "Ada Lovelace", + email: "ada@example.com", + password: "password123", + user_role: user_roles(:member) + ) + assert user.valid? + end + + test "requires email and full name" do + user = User.new(password: "password123", user_role: user_roles(:member)) + assert_not user.valid? + assert_includes user.errors[:email], "não pode ficar em branco" + assert_includes user.errors[:full_name], "não pode ficar em branco" + end + + test "normalizes email" do + user = users(:member) + user.update!(email: " RegULAR@Example.COM ") + assert_equal "regular@example.com", user.reload.email + end + + test "admin? reflects role" do + assert users(:admin).admin? + assert_not users(:member).admin? + end + + test "toggle_role! switches admin flag" do + user = users(:member) + user.toggle_role! + assert user.reload.admin? + user.toggle_role! + assert_not user.reload.admin? + end + + test "rejects invalid avatar_url" do + user = users(:member) + user.avatar_url = "not-a-url" + assert_not user.valid? + assert_includes user.errors[:avatar_url], "deve ser uma URL HTTP(S) válida" + end + + test "dashboard_stats counts roles" do + stats = User.dashboard_stats + assert_equal User.count, stats[:total] + assert_equal User.admins.count, stats[:admins] + assert_equal User.non_admins.count, stats[:non_admins] + end +end diff --git a/test/services/spreadsheet_user_importer_test.rb b/test/services/spreadsheet_user_importer_test.rb new file mode 100644 index 000000000..0d845ccbb --- /dev/null +++ b/test/services/spreadsheet_user_importer_test.rb @@ -0,0 +1,52 @@ +require "test_helper" +require "tempfile" + +class SpreadsheetUserImporterTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + test "imports users from csv" do + import = build_import(<<~CSV) + full_name,email,role + Imported One,one@import.test,no-admin + Imported Two,two@import.test,admin + CSV + + SpreadsheetUserImporter.new(import).call + + import.reload + assert_equal "completed", import.status + assert_equal 2, import.success_count + assert User.exists?(email: "one@import.test") + assert User.find_by(email: "two@import.test").admin? + end + + test "records row failures without aborting" do + import = build_import(<<~CSV) + full_name,email + Valid Person,valid@import.test + ,missing-name@import.test + CSV + + SpreadsheetUserImporter.new(import).call + import.reload + assert_equal "completed", import.status + assert_equal 1, import.success_count + assert_equal 1, import.failure_count + end + + private + + def build_import(csv_body) + import = users(:admin).imports.build(filename: "users.csv", status: "pending") + tempfile = Tempfile.new([ "users", ".csv" ]) + tempfile.write(csv_body) + tempfile.rewind + import.spreadsheet.attach( + io: tempfile, + filename: "users.csv", + content_type: "text/csv" + ) + import.save! + import + end +end diff --git a/test/system/admin_users_system_test.rb b/test/system/admin_users_system_test.rb new file mode 100644 index 000000000..b30f9e878 --- /dev/null +++ b/test/system/admin_users_system_test.rb @@ -0,0 +1,16 @@ +require "application_system_test_case" + +class AdminUsersSystemTest < ApplicationSystemTestCase + test "admin can open users index" do + visit new_session_path + fill_in "email", with: users(:admin).email + fill_in "password", with: "password123" + click_button "Entrar" + assert_text "Painel" + + visit admin_users_path + assert_current_path admin_users_path + assert_selector "h1", text: "Usuários" + assert_text users(:member).email + end +end diff --git a/test/system/authentication_system_test.rb b/test/system/authentication_system_test.rb new file mode 100644 index 000000000..a234e3eea --- /dev/null +++ b/test/system/authentication_system_test.rb @@ -0,0 +1,35 @@ +require "application_system_test_case" + +class AuthenticationSystemTest < ApplicationSystemTestCase + test "member signs in and lands on profile" do + visit new_session_path + fill_in "email", with: users(:member).email + fill_in "password", with: "password123" + click_button "Entrar" + + assert_text "Perfil" + assert_text users(:member).full_name + end + + test "admin signs in and lands on dashboard" do + visit new_session_path + fill_in "email", with: users(:admin).email + fill_in "password", with: "password123" + click_button "Entrar" + + assert_text "Painel" + assert_text "Total de usuários" + end + + test "visitor can register" do + visit new_registration_path + fill_in "user_full_name", with: "Visitante Sistema" + fill_in "user_email", with: "system.visitor@example.com" + fill_in "user_password", with: "password123" + fill_in "user_password_confirmation", with: "password123" + click_button "Criar conta" + + assert_text "Perfil" + assert_text "Visitante Sistema" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 000000000..d0b351360 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,33 @@ +if ENV["COVERAGE"] + require "simplecov" + SimpleCov.start "rails" do + add_filter "/test/" + add_filter "/config/" + add_filter "/vendor/" + minimum_coverage 90 + end +end + +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + parallelize(workers: :number_of_processors) + + fixtures :all + + def sign_in_as(user) + post session_url, params: { email: user.email, password: "password123" } + end + end +end + +module ActionDispatch + class IntegrationTest + def sign_in_as(user) + post session_url, params: { email: user.email, password: "password123" } + end + end +end diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/javascript/@rails--actioncable.js b/vendor/javascript/@rails--actioncable.js new file mode 100644 index 000000000..65d450dcd --- /dev/null +++ b/vendor/javascript/@rails--actioncable.js @@ -0,0 +1,4 @@ +// @rails/actioncable@7.2.302 downloaded from https://ga.jspm.io/npm:@rails/actioncable@7.2.302/app/assets/javascripts/actioncable.esm.js + +var e={logger:typeof console<`u`?console:void 0,WebSocket:typeof WebSocket<`u`?WebSocket:void 0},t={log(...t){this.enabled&&(t.push(Date.now()),e.logger.log(`[ActionCable]`,...t))}};const n=()=>(/* @__PURE__ */ new Date()).getTime(),r=e=>(n()-e)/1e3;class ConnectionMonitor{constructor(e){this.visibilityDidChange=this.visibilityDidChange.bind(this),this.connection=e,this.reconnectAttempts=0}start(){this.isRunning()||(this.startedAt=n(),delete this.stoppedAt,this.startPolling(),addEventListener(`visibilitychange`,this.visibilityDidChange),t.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`))}stop(){this.isRunning()&&(this.stoppedAt=n(),this.stopPolling(),removeEventListener(`visibilitychange`,this.visibilityDidChange),t.log(`ConnectionMonitor stopped`))}isRunning(){return this.startedAt&&!this.stoppedAt}recordMessage(){this.pingedAt=n()}recordConnect(){this.reconnectAttempts=0,delete this.disconnectedAt,t.log(`ConnectionMonitor recorded connect`)}recordDisconnect(){this.disconnectedAt=n(),t.log(`ConnectionMonitor recorded disconnect`)}startPolling(){this.stopPolling(),this.poll()}stopPolling(){clearTimeout(this.pollTimeout)}poll(){this.pollTimeout=setTimeout((()=>{this.reconnectIfStale(),this.poll()}),this.getPollInterval())}getPollInterval(){let{staleThreshold:e,reconnectionBackoffRate:t}=this.constructor,n=(1+t)**+Math.min(this.reconnectAttempts,10),r=(this.reconnectAttempts===0?1:t)*Math.random();return e*1e3*n*(1+r)}reconnectIfStale(){this.connectionIsStale()&&(t.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${r(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`),this.reconnectAttempts++,this.disconnectedRecently()?t.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${r(this.disconnectedAt)} s`):(t.log(`ConnectionMonitor reopening`),this.connection.reopen()))}get refreshedAt(){return this.pingedAt?this.pingedAt:this.startedAt}connectionIsStale(){return r(this.refreshedAt)>this.constructor.staleThreshold}disconnectedRecently(){return this.disconnectedAt&&r(this.disconnectedAt){(this.connectionIsStale()||!this.connection.isOpen())&&(t.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`),this.connection.reopen())}),200)}}ConnectionMonitor.staleThreshold=6,ConnectionMonitor.reconnectionBackoffRate=.15;var i={message_types:{welcome:`welcome`,disconnect:`disconnect`,ping:`ping`,confirmation:`confirm_subscription`,rejection:`reject_subscription`},disconnect_reasons:{unauthorized:`unauthorized`,invalid_request:`invalid_request`,server_restart:`server_restart`,remote:`remote`},default_mount_path:`/cable`,protocols:[`actioncable-v1-json`,`actioncable-unsupported`]};const{message_types:a,protocols:o}=i,s=o.slice(0,o.length-1),c=[].indexOf;class Connection{constructor(e){this.open=this.open.bind(this),this.consumer=e,this.subscriptions=this.consumer.subscriptions,this.monitor=new ConnectionMonitor(this),this.disconnected=!0}send(e){return this.isOpen()?(this.webSocket.send(JSON.stringify(e)),!0):!1}open(){if(this.isActive())return t.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`),!1;{let n=[...o,...this.consumer.subprotocols||[]];return t.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${n}`),this.webSocket&&this.uninstallEventHandlers(),this.webSocket=new e.WebSocket(this.consumer.url,n),this.installEventHandlers(),this.monitor.start(),!0}}close({allowReconnect:e}={allowReconnect:!0}){if(e||this.monitor.stop(),this.isOpen())return this.webSocket.close()}reopen(){if(t.log(`Reopening WebSocket, current state is ${this.getState()}`),this.isActive())try{return this.close()}catch(e){t.log(`Failed to reopen WebSocket`,e)}finally{t.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`),setTimeout(this.open,this.constructor.reopenDelay)}else return this.open()}getProtocol(){if(this.webSocket)return this.webSocket.protocol}isOpen(){return this.isState(`open`)}isActive(){return this.isState(`open`,`connecting`)}triedToReconnect(){return this.monitor.reconnectAttempts>0}isProtocolSupported(){return c.call(s,this.getProtocol())>=0}isState(...e){return c.call(e,this.getState())>=0}getState(){if(this.webSocket){for(let t in e.WebSocket)if(e.WebSocket[t]===this.webSocket.readyState)return t.toLowerCase()}return null}installEventHandlers(){for(let e in this.events){let t=this.events[e].bind(this);this.webSocket[`on${e}`]=t}}uninstallEventHandlers(){for(let e in this.events)this.webSocket[`on${e}`]=function(){}}}Connection.reopenDelay=500,Connection.prototype.events={message(e){if(!this.isProtocolSupported())return;let{identifier:n,message:r,reason:i,reconnect:o,type:s}=JSON.parse(e.data);switch(this.monitor.recordMessage(),s){case a.welcome:return this.triedToReconnect()&&(this.reconnectAttempted=!0),this.monitor.recordConnect(),this.subscriptions.reload();case a.disconnect:return t.log(`Disconnecting. Reason: ${i}`),this.close({allowReconnect:o});case a.ping:return null;case a.confirmation:return this.subscriptions.confirmSubscription(n),this.reconnectAttempted?(this.reconnectAttempted=!1,this.subscriptions.notify(n,`connected`,{reconnected:!0})):this.subscriptions.notify(n,`connected`,{reconnected:!1});case a.rejection:return this.subscriptions.reject(n);default:return this.subscriptions.notify(n,`received`,r)}},open(){if(t.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`),this.disconnected=!1,!this.isProtocolSupported())return t.log(`Protocol is unsupported. Stopping monitor and disconnecting.`),this.close({allowReconnect:!1})},close(e){if(t.log(`WebSocket onclose event`),!this.disconnected)return this.disconnected=!0,this.monitor.recordDisconnect(),this.subscriptions.notifyAll(`disconnected`,{willAttemptReconnect:this.monitor.isRunning()})},error(){t.log(`WebSocket onerror event`)}};const l=function(e,t){if(t!=null)for(let n in t)e[n]=t[n];return e};class Subscription{constructor(e,t={},n){this.consumer=e,this.identifier=JSON.stringify(t),l(this,n)}perform(e,t={}){return t.action=e,this.send(t)}send(e){return this.consumer.send({command:`message`,identifier:this.identifier,data:JSON.stringify(e)})}unsubscribe(){return this.consumer.subscriptions.remove(this)}}class SubscriptionGuarantor{constructor(e){this.subscriptions=e,this.pendingSubscriptions=[]}guarantee(e){this.pendingSubscriptions.indexOf(e)==-1?(t.log(`SubscriptionGuarantor guaranteeing ${e.identifier}`),this.pendingSubscriptions.push(e)):t.log(`SubscriptionGuarantor already guaranteeing ${e.identifier}`),this.startGuaranteeing()}forget(e){t.log(`SubscriptionGuarantor forgetting ${e.identifier}`),this.pendingSubscriptions=this.pendingSubscriptions.filter((t=>t!==e))}startGuaranteeing(){this.stopGuaranteeing(),this.retrySubscribing()}stopGuaranteeing(){clearTimeout(this.retryTimeout)}retrySubscribing(){this.retryTimeout=setTimeout((()=>{this.subscriptions&&typeof this.subscriptions.subscribe==`function`&&this.pendingSubscriptions.map((e=>{t.log(`SubscriptionGuarantor resubscribing ${e.identifier}`),this.subscriptions.subscribe(e)}))}),500)}}class Subscriptions{constructor(e){this.consumer=e,this.guarantor=new SubscriptionGuarantor(this),this.subscriptions=[]}create(e,t){let n=e,r=typeof n==`object`?n:{channel:n},i=new Subscription(this.consumer,r,t);return this.add(i)}add(e){return this.subscriptions.push(e),this.consumer.ensureActiveConnection(),this.notify(e,`initialized`),this.subscribe(e),e}remove(e){return this.forget(e),this.findAll(e.identifier).length||this.sendCommand(e,`unsubscribe`),e}reject(e){return this.findAll(e).map((e=>(this.forget(e),this.notify(e,`rejected`),e)))}forget(e){return this.guarantor.forget(e),this.subscriptions=this.subscriptions.filter((t=>t!==e)),e}findAll(e){return this.subscriptions.filter((t=>t.identifier===e))}reload(){return this.subscriptions.map((e=>this.subscribe(e)))}notifyAll(e,...t){return this.subscriptions.map((n=>this.notify(n,e,...t)))}notify(e,t,...n){let r;return r=typeof e==`string`?this.findAll(e):[e],r.map((e=>typeof e[t]==`function`?e[t](...n):void 0))}subscribe(e){this.sendCommand(e,`subscribe`)&&this.guarantor.guarantee(e)}confirmSubscription(e){t.log(`Subscription confirmed ${e}`),this.findAll(e).map((e=>this.guarantor.forget(e)))}sendCommand(e,t){let{identifier:n}=e;return this.consumer.send({command:t,identifier:n})}}class Consumer{constructor(e){this._url=e,this.subscriptions=new Subscriptions(this),this.connection=new Connection(this),this.subprotocols=[]}get url(){return u(this._url)}send(e){return this.connection.send(e)}connect(){return this.connection.open()}disconnect(){return this.connection.close({allowReconnect:!1})}ensureActiveConnection(){if(!this.connection.isActive())return this.connection.open()}addSubProtocol(e){this.subprotocols=[...this.subprotocols,e]}}function u(e){if(typeof e==`function`&&(e=e()),e&&!/^wss?:/i.test(e)){let t=document.createElement(`a`);return t.href=e,t.href=t.href,t.protocol=t.protocol.replace(`http`,`ws`),t.href}else return e}function d(e=f(`url`)||i.default_mount_path){return new Consumer(e)}function f(e){let t=document.head.querySelector(`meta[name='action-cable-${e}']`);if(t)return t.getAttribute(`content`)}export{Connection,ConnectionMonitor,Consumer,i as INTERNAL,Subscription,SubscriptionGuarantor,Subscriptions,e as adapters,d as createConsumer,u as createWebSocketURL,f as getConfig,t as logger}; +