diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..be1382e95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,65 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle +/vendor/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 +# Vite output (vite, vite-dev, vite-test, vite-ssr, ...) is rebuilt inside the image. +/public/vite* + +# Ignore test suite and its artifacts. +/spec +/coverage +/test-results +/playwright-report + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer +/.vscode +/.idea + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* + +# Ignore OS cruft. +.DS_Store \ No newline at end of file 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..9e5631706 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +# .github/workflows/ci.yml +name: CI +on: [push, pull_request] + +jobs: + ci: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + + steps: + - uses: actions/checkout@v7 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 4.0.6 + bundler-cache: true + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + + - run: npm ci + - run: npx playwright install --with-deps chromium + + - run: bin/rails parallel:create parallel:load_schema + + - run: bundle exec rubocop + - run: bundle exec brakeman --quiet --no-pager --exit-on-warn + - run: bundle exec bundler-audit check --update + - run: npm run typecheck + - run: npm run format + - run: bundle exec parallel_rspec + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: coverage + path: coverage/ diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4b950cc66..1d2df9f89 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -8,44 +8,31 @@ on: jobs: CodeQL-Build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - # Override language selection by uncommenting this and choosing your languages - # with: - # languages: go, javascript, csharp, python, cpp, java - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + # The default GITHUB_TOKEN is read-only; uploading results needs security-events: write. + permissions: + actions: read + contents: read + security-events: write - # ℹ️ Command-line programs to run using the OS shell. - # πŸ“š https://git.io/JvXDl + strategy: + fail-fast: false + matrix: + # All interpreted, so nothing needs to be built before analysis. + language: [actions, javascript-typescript, ruby] - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: none + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..2efddeb3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# 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 +/config/credentials/*.key + + +/app/assets/builds/* +!/app/assets/builds/.keep + +# Vite Ruby +/public/vite* +node_modules +# Vite uses dotenv and suggests to ignore local-only env files. See +# https://vitejs.dev/guide/env-and-mode.html#env-files +*.local + +# Ignore SimpleCov coverage reports. +/coverage/ + +# Ignore Playwright traces and reports from browser specs. +/test-results/ +/playwright-report/ + +# SSH key for the local Kamal test server (.kamal/local). +/.kamal/local/ssh/ diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 000000000..a0b053784 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 000000000..7d2a13db2 --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 000000000..17b0567a5 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 000000000..84548ed04 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 000000000..1f9fe844c --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 000000000..d53d28cf7 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/usr/bin/env sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 000000000..77744bdca --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 000000000..05b3055b7 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 000000000..93e11991d --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/local/Dockerfile b/.kamal/local/Dockerfile new file mode 100644 index 000000000..a5dc783ae --- /dev/null +++ b/.kamal/local/Dockerfile @@ -0,0 +1,13 @@ +# Stand-in for a production host: root SSH access plus its own Docker daemon. +FROM docker:29-dind + +# Kamal's local registry reaches this host through an SSH remote port forward, +# which Alpine's sshd disables by default. +RUN apk add --no-cache openssh-server && \ + ssh-keygen -A && \ + sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /etc/ssh/sshd_config + +COPY --chmod=755 entrypoint.sh /usr/local/bin/server-entrypoint.sh + +EXPOSE 22 80 +ENTRYPOINT ["server-entrypoint.sh"] diff --git a/.kamal/local/compose.yml b/.kamal/local/compose.yml new file mode 100644 index 000000000..90d169100 --- /dev/null +++ b/.kamal/local/compose.yml @@ -0,0 +1,24 @@ +# Fake server for `bin/kamal -d local` (see config/deploy.local.yml). +# +# ssh-keygen -t ed25519 -N "" -f .kamal/local/ssh/id_ed25519 # once +# docker compose -f .kamal/local/compose.yml up -d --build +# docker compose -f .kamal/local/compose.yml down -v # wipe the server +name: umanni-users-kamal-local + +services: + server: + build: . + privileged: true + hostname: kamal-local + ports: + - "127.0.0.1:2222:22" + - "127.0.0.1:8080:80" + volumes: + - ./ssh/id_ed25519.pub:/keys/authorized_keys:ro + # Persist containers and Kamal's host directories (e.g. postgres data) across restarts. + - docker-data:/var/lib/docker + - root-home:/root + +volumes: + docker-data: + root-home: diff --git a/.kamal/local/entrypoint.sh b/.kamal/local/entrypoint.sh new file mode 100644 index 000000000..aa7ba3e8d --- /dev/null +++ b/.kamal/local/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +install -d -m 700 /root/.ssh +install -m 600 /keys/authorized_keys /root/.ssh/authorized_keys +/usr/sbin/sshd -e + +# Unix socket only: skips the TLS cert generation and TCP listener of the default dind setup. +exec dockerd-entrypoint.sh dockerd --host=unix:///var/run/docker.sock diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 000000000..ae23b1c5e --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,5 @@ +RAILS_MASTER_KEY=$(cat config/master.key) +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +POSTGRES_PASSWORD=$POSTGRES_PASSWORD +AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID +AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..707210db8 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26.8.2 diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..75a894a27 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..c99d2e739 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..e5b9b9ac0 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,32 @@ +inherit_gem: + rubocop-rails-omakase: rubocop.yml + +plugins: + - rubocop-rspec + +AllCops: + TargetRubyVersion: 4.0 + NewCops: enable + Exclude: + - "db/**/*" + - "bin/**/*" + - "vendor/**/*" + - "node_modules/**/*" + +# Omakase disables the whole Layout department, so this cop must be enabled explicitly. +Layout/LineLength: + Enabled: true + Max: 120 + +# In controller specs, `controller(...) do ... end` is a class body, where described_class isn't defined. +RSpec/DescribedClass: + SkipBlocks: true + +RSpec/ExampleLength: + Max: 15 + +RSpec/MultipleExpectations: + Max: 4 + +RSpec/NestedGroups: + Max: 4 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/AI_DISCLOSURE.md b/AI_DISCLOSURE.md new file mode 100644 index 000000000..210f96f87 --- /dev/null +++ b/AI_DISCLOSURE.md @@ -0,0 +1,45 @@ +# AI Usage Disclosure + +This document describes how AI assistants were used while building this project, in line with Umanni's AI policy. + +## Models used + +| Tool / Model | Primary use | +|---|---| +| **GitHub Copilot** | Inline code completion while writing code | +| **Claude Opus 5** (Anthropic) | Planning, bug fixing, and code / Pull Request reviews | + +Copilot was used for code completion. Claude Opus 5 was used for planning, debugging, and reviews. + +## Scope of use + +### 1. Planning the first steps +AI helped break the task into initial steps before implementation started: project setup, domain model (`User` with `full_name`, `email_address`, `avatar_image`, `role`), authentication, the admin dashboard, and the spreadsheet import flow. + +### 2. Understanding the requirements +AI was used to go through the test requirements in more depth. For example: +- what the Rails 8 native stack requires (Solid Queue for background imports, Solid Cable for real-time updates, built-in authentication instead of Devise); +- how the admin, user, and visitor use cases map to routes, controllers, and authorization rules. + +### 3. Choosing the frontend stack +The requirements offer two frontend options: +- **Option A:** Hotwire (Turbo / Stimulus) +- **Option B:** React integrated via Inertia.js + +AI helped compare the trade-offs between them. **Inertia.js + React with Vite Rails** was chosen instead of pure Rails with Stimulus. + +### 4. Learning technologies not used before +AI served as a learning aid for technologies I had not used before, mainly **Inertia.js** and its integration with Rails (`inertia_rails`), React, and Vite (`vite_rails`). This covered explaining concepts, setup, and how the pieces connect, and helped get them working in this project. + +### 5. Bug fixing +Claude Opus 5 helped investigate and fix bugs found during development. + +### 6. Pull Request reviews and descriptions +AI was used to: +- review Pull Requests before merging, pointing out correctness issues and possible improvements; +- write Pull Request descriptions summarizing the changes in each PR. + +### 7. Documentation and requirements review +Claude Opus 5 was used to: +- write project documentation (`README.md`, `DEVELOPMENT_SETUP.md`, `KAMAL_DISCLOUSURE.md`); +- check the application against the challenge requirements and implement the gaps it found: client-side form validation, parallel test execution, removing unused Hotwire/importmap and Redis configuration, and running the Solid Queue worker inside Puma in Docker Compose. diff --git a/CHALLENGE.md b/CHALLENGE.md new file mode 100644 index 000000000..7829f14ff --- /dev/null +++ b/CHALLENGE.md @@ -0,0 +1,87 @@ +# Modern Fullstack Developer Test (Rails 8 / Ruby 4) + +- Check this readme.md +- Create a branch to develop your task +- Push to remote in 1 week (date will be checked from branch creation/assigned date) + +# Requirements: +- Target Stack: **Ruby 4.0+** and **Rails 8.0+** +- Database: PostgreSQL, MySQL, or SQLite (configured for production-ready WAL mode) +- Write robust unit, integration, and system tests using parallel testing features +- Deliver with a working multi-stage Dockerfile utilizing Thruster/Kamal-ready defaults +- Show senior best practices (e.g., proper design patterns, solid architecture, strict linter configuration) + +# Our AI Policy +At Umanni, we value efficiency and the modern developer workflow. **You are allowed to use AI coding assistants (ChatGPT, Claude, Copilot, etc.) during this test.** However, transparency is part of our culture. If you use any LLM to generate, refactor, or structure your code, **you must explicitly state which model you used** in a dedicated section at the top of your submission's README.md. Failing to disclose AI usage while using it will invalidate your submission. + +# The Test +Here we'll try to simulate a "real sprint" that you'll probably be assigned while working as Fullstack at Umanni. + +# The Task +- Create a modern, responsive application to manage users. +- A user must have: + 1. full_name + 2. email + 3. avatar_image (ActiveStorage file upload or remote URL) + 4. role (admin/no-admin) + +# The App +## Admin Use cases +- As an Admin, I must be able to access a User Admin Dashboard. +- As an Admin, I must be able to see on the Dashboard (updated via real-time streams/frontend state): + - Total number of Users + - Total number of Users grouped by Role +- As an Admin, I must be redirected to the User Admin Dashboard after login. +- As an Admin, I must be able to list, create, edit, and delete Users. +- As an Admin, I must be able to toggle the User Role. +- As an Admin, I must be able to import a Spreadsheet (.csv/.xlsx) into the system in order to asynchronously create new Users. +- As an Admin, I must be able to see the live progress/status of the spreadsheet import process. + +## User Use Cases +- As a User, I must be redirected to my Profile after login. +- As a User, I must be able only to see my info, edit, and delete my profile. + +## Visitor Use Cases +- As a Visitor, I can register myself as a normal User. + + + +# The Start. +- Your deadline is 1 week after accepting this test. + +# The Rules (Strict Compliance) +These are mandatory. Failing any of them will invalidate your submission. +- **Documentation**: You must write down a detailed README.md in English explaining how to build, seed, and run your app, including your AI disclosure if applicable. +- **Frontend Stack**: You have two choices for the modern monolithic approach: + - **Option A (Classic Modern):** Hotwire (Turbo 8+ / Stimulus) with smooth, reactive UI states. + - **Option B (Modern SPA Monolith):** **React integrated via Inertia.js** (using Vite or the official Rails 8 asset pipeline integration). +- **Styling**: The Frontend must use a modern CSS framework (Tailwind CSS, Bootstrap, or any utility-first library). Keep it beautiful, responsive, and clean. +- **Real-time & Background Processing**: You must leverage native Rails 8 tools (**Solid Cable** for live dashboard counters/import bars and **Solid Queue** for the background import processing). No Redis installation should be required. +- **Authentication**: You must use the new built-in Rails 8 Authentication system (`bin/rails generate authentication`), customized to fit the role constraints. Avoid legacy heavy gems (like Devise). +- **Git Hygiene**: Clean git history with atomic commits, proper descriptions, and a Pull Request-based workflow. + +# What we're expecting to see: +- Modern asset management using **Propshaft** or **Vite Rails** (if choosing Inertia/React). +- .gitignore, .dockerignore configured correctly. +- Clean application configuration using Rails credentials. +- Comprehensive cross-browser support considerations. +- Strict form validations (Frontend interactive feedback + Backend structural validation). +- Parallel testing with at least 90% coverage (using Minitest, RSpec, and Playwright/Capybara for frontend integration). + +# Extra points +- Delivery via a clean **Kamal 2** deployment configuration (`deploy.yml`). +- Advanced SSR (Server-Side Rendering) setup if using **Inertia.js + React**. +- Use of **Thruster** as a zero-config proxy for asset caching and compression in Docker. +- Advanced performance profiling leveraging Ruby 4's **ZJIT** compilation optimizations. + +# What will be assessed +- 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. diff --git a/DEVELOPMENT_SETUP.md b/DEVELOPMENT_SETUP.md new file mode 100644 index 000000000..b8c4ce99e --- /dev/null +++ b/DEVELOPMENT_SETUP.md @@ -0,0 +1,392 @@ +# Development Setup with Docker Compose + +This guide explains how to build and run the Umanni Users app locally with Docker Compose, and what happens inside the containers when you do. + +- [1. What runs](#1-what-runs) +- [2. Prerequisites](#2-prerequisites) +- [3. Configure `.env`](#3-configure-env) +- [4. Build the image](#4-build-the-image--docker-compose-build) +- [5. Start the stack](#5-start-the-stack--docker-compose-up) +- [6. How a request flows through the system](#6-how-a-request-flows-through-the-system) +- [7. Background jobs (Solid Queue)](#7-background-jobs-solid-queue) +- [8. Seed the database](#8-seed-the-database) +- [9. Day-to-day development workflow](#9-day-to-day-development-workflow) +- [10. Command reference](#10-command-reference) +- [11. Data and persistence](#11-data-and-persistence) +- [12. Troubleshooting](#12-troubleshooting) +- [13. Known gaps in the current setup](#13-known-gaps-in-the-current-setup) + +--- + +## 1. What runs + +[docker-compose.yml](docker-compose.yml) defines two services on a private bridge network called `umanni-test`: + +| Service | Container name | Image | Purpose | Reachable from your machine | +|---|---|---|---|---| +| `db` | `umanni-pg` | `postgres:17` | Primary database, plus the Solid Queue, Solid Cable, and Solid Cache databases | No (only inside the network, port 5432) | +| `web` | `umanni-users` | Built from [Dockerfile](Dockerfile) | Rails 8.1 + Puma behind Thruster, with the Solid Queue worker running inside Puma | **Yes: http://localhost:3000** | + +There is no Redis: cache, background jobs, and Action Cable all run on PostgreSQL through the Solid adapters. Containers find each other by container name, so Rails connects to Postgres at `DB_HOST=umanni-pg`. + +--- + +## 2. Prerequisites + +- **Docker Desktop** (or another engine) with **Compose v2**. This guide uses `docker compose ...`. The legacy `docker-compose ...` binary accepts the same commands. +- **The Rails master key.** Ask a teammate for it, or copy it from `config/master.key` if you already have one. You need it because: + - `User#email_address` is encrypted with Active Record Encryption, and its keys live in `config/credentials.yml.enc`. + - `config/master.key` is excluded from the image by [.dockerignore](.dockerignore), so the key has to come in through the `RAILS_MASTER_KEY` environment variable. +- Port **3000** free on your machine. + +> Docker Desktop on macOS installs its CLI in `~/.docker/bin`. If your shell prints `docker: command not found`, add `export PATH="$HOME/.docker/bin:$PATH"` to your shell profile. + +--- + +## 3. Configure `.env` + +Compose automatically reads a `.env` file next to `docker-compose.yml` and substitutes its values into every `${VAR}` in the file. `.env` is ignored by git ([.gitignore](.gitignore)) and never copied into the image ([.dockerignore](.dockerignore)), so secrets stay on your machine. + +Create `.env` in the project root: + +```dotenv +RAILS_MASTER_KEY= +SECRET_KEY_BASE= +RAILS_ENV=development +RACK_ENV=development +``` + +The `web` service gets the following environment. Some values are hardcoded in `docker-compose.yml`, so setting them in `.env` has **no effect**: + +| Variable | Value / source | Used by | +|---|---|---| +| `RAILS_MASTER_KEY` | `.env` | Decrypting credentials, including the Active Record Encryption keys | +| `SECRET_KEY_BASE` | `.env` | Signing sessions and cookies | +| `RAILS_ENV`, `RACK_ENV` | `.env` (the image also defaults `RAILS_ENV=development`) | Rails environment selection | +| `DB_HOST` | hardcoded `umanni-pg` | [config/database.yml](config/database.yml) | +| `POSTGRES_USER` / `POSTGRES_PASSWORD` | hardcoded `umanni` / `secret` | [config/database.yml](config/database.yml) | +| `APP_ORIGIN` | hardcoded `http://localhost:3000` | Allowed Action Cable origins ([development.rb](config/environments/development.rb)). Boot fails without it. | +| `SOLID_QUEUE_IN_PUMA` | hardcoded `true` | Starts the Solid Queue worker inside Puma ([config/puma.rb](config/puma.rb)) | + +Development stores uploads on local disk, so no AWS variables are needed. + +--- + +## 4. Build the image β€” `docker compose build` + +```bash +docker compose build # builds the `web` image (db is pulled, not built) +``` + +The first build downloads Ruby, Node, and all gems and npm packages, so it takes several minutes. Later builds reuse cached layers and take about 15–20 seconds when only application code changed. + +The resulting image is named `fullstack-developer-web:latest`. [Dockerfile](Dockerfile) is a **multi-stage** build: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ base ────────────────┐ +β”‚ ruby:4.0.6-slim β”‚ +β”‚ + curl, libjemalloc2, libvips, β”‚ +β”‚ postgresql-client β”‚ +β”‚ ENV RAILS_ENV=development β”‚ +β”‚ BUNDLE_WITHOUT=development:test β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ build ────────────────────────────────┐ + β”‚ + build-essential, libpq-dev, git, Node 26.8.2 β”‚ + β”‚ 1. bundle install (Gemfile / Gemfile.lock layer β€” cached) β”‚ + β”‚ 2. npm ci (package-lock.json layer β€” cached) β”‚ + β”‚ 3. COPY . . (application code) β”‚ + β”‚ 4. bootsnap precompile β”‚ + β”‚ 5. bin/rails assets:precompile β”‚ + β”‚ β†’ Vite build (React + Tailwind) into public/vite-dev/ β”‚ + β”‚ β†’ Vite SSR build (Node server) into public/vite-ssr/ β”‚ + β”‚ 6. rm -rf node_modules β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ copy /usr/local/bundle, /rails and the node binary + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ final ───────────┐ + β”‚ base + gems + app + compiled assets β”‚ + β”‚ + node, for the Inertia SSR server β”‚ + β”‚ runs as non-root user `rails` (1000) β”‚ + β”‚ ENTRYPOINT bin/docker-entrypoint β”‚ + β”‚ CMD ./bin/thrust ./bin/rails server β”‚ + β”‚ EXPOSE 80 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Things to know about the build: + +- **Layer order matters for speed.** Gems and npm packages are installed *before* `COPY . .`. Editing app code reuses those layers. Changing `Gemfile.lock` or `package-lock.json` triggers a full reinstall. +- **Frontend assets are compiled at build time.** Vite is the only asset pipeline: it bundles the React app and builds Tailwind through `@tailwindcss/vite`. Because `RAILS_ENV=development`, it writes to `public/vite-dev/` (see [config/vite.json](config/vite.json)). The same step runs the SSR build (`ssrBuildEnabled`), which bundles React and Inertia into a self-contained Node server, `public/vite-ssr/ssr.js`. `node_modules` is removed from the final image, but the `node` binary stays to run that server. At runtime, vite_ruby logs `Skipping vite build. Watched files have not changed since the last build`. +- **Development and test gems are not installed** (`BUNDLE_WITHOUT=development:test`). `web-console`, `rspec`, `rubocop`, `brakeman`, and `dotenv` are therefore **not** in the container. Run tests and linters on your host (or in CI), not in this image. `faker` is a top-level gem, so seeds do work. +- **Secrets are never baked in.** `.env*`, `config/master.key`, `spec/`, `.git/`, `node_modules/`, and `log/`/`tmp/` contents are all in [.dockerignore](.dockerignore). + +Force a clean rebuild with no cache: + +```bash +docker compose build --no-cache web +``` + +--- + +## 5. Start the stack β€” `docker compose up` + +```bash +docker compose up -d # start in the background +docker compose logs -f web # follow the Rails/Thruster logs (Ctrl-C stops following, not the app) +``` + +Or run in the foreground (logs in your terminal, Ctrl-C stops everything): + +```bash +docker compose up +``` + +Build and start in one step: + +```bash +docker compose up -d --build +``` + +Then open **http://localhost:3000**. Health check: `curl http://localhost:3000/up` returns `200`. + +### Startup sequence + +``` +docker compose up +β”‚ +β”œβ”€ db (umanni-pg) +β”‚ β”œβ”€ empty pg_data volume? β†’ create user `umanni`, database `umanni_users_development`, +β”‚ β”‚ run config/postgres/init.sql (first boot only) +β”‚ └─ healthcheck: pg_isready every 5s ───────────────┐ +β”‚ β”‚ depends_on: service_healthy +└─ web (umanni-users) β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + └─ bin/docker-entrypoint ./bin/thrust ./bin/rails server + β”œβ”€ args end in "./bin/rails server" β†’ ./bin/rails db:prepare + β”‚ creates any missing databases, loads schema or runs pending migrations for + β”‚ primary, queue, cache, and cable + └─ exec ./bin/thrust ./bin/rails server + β”œβ”€ Thruster listens on :80 (container) ← published as localhost:3000 + └─ Puma listens on 127.0.0.1:3000 (inside the container only) + β”œβ”€ SOLID_QUEUE_IN_PUMA=true β†’ Solid Queue supervisor, dispatcher and worker + └─ inertia_ssr plugin β†’ node public/vite-ssr/ssr.js on :13714 (not published) +``` + +Key points: + +1. **`web` waits until Postgres reports healthy** (`depends_on: condition: service_healthy`). It won't start before the database accepts connections. +2. **Migrations run automatically on every boot.** [bin/docker-entrypoint](bin/docker-entrypoint) runs `db:prepare` whenever the command ends in `./bin/rails server`. On a fresh volume it creates and loads all four development databases. On an existing one it only applies pending migrations. Commands like `docker compose exec web ./bin/rails console` skip this step. +3. **The `3000:80` port mapping is intentional.** Thruster (an HTTP/2 proxy that handles gzip, asset caching, and X-Sendfile) listens on port 80 and forwards to Puma on port 3000 *inside* the container. Browser traffic always goes through Thruster. +4. **Background jobs start with the server.** Puma's `solid_queue` plugin forks the Solid Queue supervisor, so imports are processed without a separate container ([section 7](#7-background-jobs-solid-queue)). +5. During the first second or two you may see `Unable to proxy request ... connection refused`. Thruster starts before Puma finishes booting, and the message stops once Puma is listening. +6. **Pages arrive server-rendered.** Once Puma has booted, its `inertia_ssr` plugin starts the Node SSR server (log line `Inertia SSR: server ready`) and restarts it if it crashes. Rails sends each Inertia page to it and puts the returned HTML in the response, and React hydrates that HTML in the browser. If the SSR server is down, pages still work: they render in the browser instead. `INERTIA_SSR_ENABLED=false` turns SSR off. + +### Databases + +All four logical databases live in the single `umanni-pg` server: + +| Rails role | Database | Purpose in development | +|---|---|---| +| `primary` | `umanni_users_development` | Users, sessions, imports, Active Storage records | +| `queue` | `umanni_users_development_queue` | Solid Queue job tables | +| `cable` | `umanni_users_development_cable` | Solid Cable pub/sub messages (real-time dashboard and import progress) | +| `cache` | `umanni_users_development_cache` | Created by `db:prepare`, but development uses `:memory_store` | + +[config/postgres/init.sql](config/postgres/init.sql) also creates `umanni_users_production_{queue,cache,cable}`. That script is shared with the Kamal Postgres accessory. The production databases are unused in development and harmless. + +--- + +## 6. How a request flows through the system + +``` +Browser ──http://localhost:3000──► Docker port map ──► Thruster :80 ──► Puma 127.0.0.1:3000 ──► Rails + (gzip, asset β”‚ + caching) β”œβ”€β–Ί Postgres primary (umanni-pg) + β”‚ +Browser ──ws://localhost:3000/cable──────────────────────────────────────────► Action Cable ────── + β”‚ β”‚ + polls every 0.1s β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + Postgres cable DB (Solid Cable) β”‚ + β”‚ +Admin uploads spreadsheet ──► ProcessImportJob.perform_later ──► Postgres queue DB ──► Solid Queue worker + (inside Puma) +``` + +- **Pages** are Rails controllers rendering Inertia.js responses. On a full page load, Rails first posts the page to the Inertia SSR server (`127.0.0.1:13714`, inside the container) and sends back its HTML, so content shows before any JavaScript runs. React components then come from the prebuilt bundle in `public/vite-dev/`, served by Thruster, and hydrate that HTML. Later navigation is client-side, as before. +- **Real-time updates** (dashboard counters, import progress) use Action Cable at `/cable` on the Solid Cable adapter. Solid Cable stores messages in the `cable` database and polls it. +- **Background work** (spreadsheet imports on the `imports` queue, debounced dashboard broadcasts on `default`) is enqueued into the `queue` database through Solid Queue and picked up by the worker running inside Puma. + +--- + +## 7. Background jobs (Solid Queue) + +`docker-compose.yml` sets `SOLID_QUEUE_IN_PUMA=true`, so [config/puma.rb](config/puma.rb) loads the `solid_queue` plugin. When Puma boots, it forks a Solid Queue supervisor with one dispatcher and one worker. The worker has 3 threads and listens on all queues (see [config/queue.yml](config/queue.yml)). The worker starts and stops together with the web server: nothing to run by hand. + +Check that it registered: + +```bash +docker compose exec db psql -U umanni -d umanni_users_development_queue \ + -c "select kind, hostname, pid from solid_queue_processes;" +``` + +You should see a `Supervisor(fork)`, a `Dispatcher` and a `Worker` row. + +To run the worker in the foreground instead, for example to watch job logs in isolation, remove `SOLID_QUEUE_IN_PUMA` from `docker-compose.yml`, run `docker compose up -d`, and start it by hand: + +```bash +docker compose exec web ./bin/jobs +``` + +In production, Kamal runs jobs in a dedicated `job` role instead ([config/deploy.yml](config/deploy.yml)). + +--- + +## 8. Seed the database + +```bash +docker compose exec web ./bin/rails db:seed +``` + +[db/seeds.rb](db/seeds.rb) creates: + +- **Admin:** `admin@umanni.test` / `password123`. To choose the password, pass `-e SEED_ADMIN_PASSWORD=...` to `docker compose exec`. The variable isn't forwarded from `.env`. +- **25 regular members** with Faker names and pravatar avatars, password `password123`. + +The admin is created only once, but **every run adds 25 more members**, because Faker generates new unique emails each time. + +Reset everything (drop, recreate, load schema, seed): + +```bash +docker compose exec web ./bin/rails db:reset +``` + +--- + +## 9. Day-to-day development workflow + +**The source code is not mounted into the container.** `web` runs the snapshot of the code that was copied in at build time. Rails' code reloading is enabled, but it only sees files inside the image. Edits on your host do **not** show up until you rebuild. + +The loop for picking up changes: + +```bash +# edit code on your host, then: +docker compose up -d --build web # rebuild the image (β‰ˆ15–20s with cache) and recreate the container +docker compose logs -f web +``` + +`db` keeps running, and its data persists in a named volume. Pending migrations run automatically when the new `web` container boots, and the job worker restarts with Puma. + +| You changed... | What to run | +|---|---| +| Ruby, views, React/TS components, CSS | `docker compose up -d --build web` | +| A new migration | `docker compose up -d --build web` (the entrypoint migrates on boot) | +| `Gemfile` / `package.json` | Update the lockfile on the host first (`bundle install` / `npm install`), then `docker compose up -d --build web` (slower: gem/npm layers rebuild) | +| `docker-compose.yml` environment | `docker compose up -d` (Compose recreates the containers that changed) | +| `.env` values | `docker compose up -d` | + +### Hot reload (hybrid mode) + +For fast feedback (Vite HMR, instant Ruby reloads, tests, linters), run the Rails processes on your host with `bin/dev`, and use Compose only for Postgres. [Procfile.dev](Procfile.dev) starts Puma, the Vite dev server, and a Solid Queue worker (`bin/jobs`). This mode isn't configured out of the box: + +1. Publish the Postgres port. Add this to the `db` service: + ```yaml + ports: + - "5432:5432" + ``` +2. `docker compose up -d db` +3. On your host (Ruby 4.0.6 and Node 26 installed (`nvm use` reads `.nvmrc`), after `bundle install && npm install`), export the variables Rails expects. `.env` is not loaded automatically outside Compose: + ```bash + export DB_HOST=localhost POSTGRES_USER=umanni POSTGRES_PASSWORD=secret \ + APP_ORIGIN=http://localhost:3000 + bin/dev + ``` + +Don't run the hybrid `bin/dev` and the `web` container at the same time. Both want port 3000. + +--- + +## 10. Command reference + +| Task | Command | +|---|---| +| Build the image | `docker compose build` | +| Start everything (background) | `docker compose up -d` | +| Rebuild and restart the app | `docker compose up -d --build web` | +| Service status and health | `docker compose ps` | +| Follow app logs | `docker compose logs -f web` | +| Rails console | `docker compose exec web ./bin/rails console` | +| Shell in the app container | `docker compose exec web bash` | +| Run migrations manually | `docker compose exec web ./bin/rails db:migrate` | +| Seed | `docker compose exec web ./bin/rails db:seed` | +| Routes | `docker compose exec web ./bin/rails routes` | +| psql (primary DB) | `docker compose exec db psql -U umanni -d umanni_users_development` | +| Solid Queue processes | `docker compose exec db psql -U umanni -d umanni_users_development_queue -c "select kind from solid_queue_processes;"` | +| Stop (keep containers and data) | `docker compose stop` | +| Stop and remove containers (keep data) | `docker compose down` | +| **Wipe everything, including the database and uploads** | `docker compose down -v` ⚠️ irreversible | +| Clean image rebuild | `docker compose build --no-cache web` | + +Commands run through `docker compose exec` execute as the non-root `rails` user (UID 1000) in `/rails`. + +--- + +## 11. Data and persistence + +| Data | Where it lives | Survives `down` / `up --build`? | +|---|---|---| +| Postgres (all four databases) | Named volume `fullstack-developer_pg_data` | βœ… Yes. Lost only with `down -v` | +| Uploaded avatars and import files (Active Storage, `:local` service) | Named volume `fullstack-developer_storage_data`, mounted at `/rails/storage` | βœ… Yes. Lost only with `down -v` | +| Logs | Container stdout (`docker compose logs`) and `/rails/log` | ❌ No | + +`config/postgres/init.sql` runs **only when `pg_data` is empty**. Editing it has no effect on an existing database unless you wipe the volume. + +--- + +## 12. Troubleshooting + +**`web` exits immediately, or errors mention credentials or encryption.** +`RAILS_MASTER_KEY` is missing or wrong. Rails can't decrypt `config/credentials.yml.enc`, and so has no Active Record Encryption keys for `User#email_address`. Check `.env`, then `docker compose up -d`. + +**`Bind for 0.0.0.0:3000 failed: port is already allocated`** +Something else is using port 3000, often a host `bin/dev`. Stop it, or change the mapping to `"3001:80"`. If you change the port, also update `APP_ORIGIN` to `http://localhost:3001`, or Action Cable will reject WebSocket connections. + +**`Found orphan containers ([umanni-redis])`** +An older version of this stack ran Redis. It's no longer used. Remove the leftover container with `docker compose up -d --remove-orphans`, and the old volume with `docker volume rm fullstack-developer_redis_data`. + +**`Conflict. The container name "/umanni-pg" is already in use`** +The containers have fixed names, so only one copy of this stack can exist per Docker engine. Remove the old one (`docker rm -f umanni-pg umanni-users`), or run `docker compose down` from the other checkout. + +**Code changes don't appear.** +Expected: the code is baked into the image. Run `docker compose up -d --build web` ([section 9](#9-day-to-day-development-workflow)). + +**Imports stuck at "pending".** +The job worker isn't running. Check `solid_queue_processes` ([section 7](#7-background-jobs-solid-queue)), make sure `SOLID_QUEUE_IN_PUMA=true` is still set on `web`, and look for Solid Queue errors in `docker compose logs web`. + +**Real-time updates don't arrive (WebSocket fails).** +`APP_ORIGIN` must exactly match the URL in your browser, including scheme and port (`http://localhost:3000`). Using `127.0.0.1:3000` instead of `localhost:3000` fails the origin check. + +**`ActiveRecord::PendingMigrationError` in the browser.** +Migrations normally run on boot. If you ran `db:rollback` or similar by hand, run `docker compose exec web ./bin/rails db:migrate`. + +**`Unable to proxy request ... connection refused` in the logs right after start.** +Harmless: Thruster started before Puma. It stops once Puma prints `Listening on http://127.0.0.1:3000`. + +**`No route matches [GET] "/.well-known/appspecific/com.chrome.devtools.json"`** +Harmless: Chrome DevTools probes for this file. + +**Start completely fresh.** +```bash +docker compose down -v # ⚠️ deletes the database and uploads volumes +docker compose build --no-cache +docker compose up -d +docker compose exec web ./bin/rails db:seed +``` + +--- + +## 13. Known gaps in the current setup + +- **The image is built with `RAILS_ENV=development`.** [Dockerfile](Dockerfile) hardcodes `RAILS_ENV=development` for this Compose setup, and [config/deploy.yml](config/deploy.yml) doesn't override it. A Kamal deploy would boot in development mode unless `RAILS_ENV: production` is added to `env.clear` (see [KAMAL_DISCLOUSURE.md](KAMAL_DISCLOUSURE.md)). + +### Not to be confused with: the Kamal local rehearsal + +[.kamal/local/compose.yml](.kamal/local/compose.yml) is a **separate** Compose file. It starts a fake "production server" (SSH + Docker-in-Docker) for rehearsing `bin/kamal deploy -d local`, served on http://localhost:8080. It has nothing to do with the development stack described here. See the comments in [config/deploy.local.yml](config/deploy.local.yml). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..d67d8ed92 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,84 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# Production image, meant for Kamal or a manual build'n'run: +# docker build -t fullstack_developer . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= -e APP_ORIGIN=https://example.com --name fullstack_developer fullstack_developer + +# Must match .ruby-version and the `ruby` line in the Gemfile. +ARG RUBY_VERSION=4.0.6 +# Must match .nvmrc (CI reads it through actions/setup-node). +ARG NODE_VERSION=26.8.2 + +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + curl libjemalloc2 libvips postgresql-client && \ + 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 + +# jemalloc is only used if preloaded. +ENV RAILS_ENV=development \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_PATH=/usr/local/bundle \ + LD_PRELOAD=/usr/local/lib/libjemalloc.so + +# ---------- build ---------- +FROM base AS build + +ARG NODE_VERSION +ENV PATH=/usr/local/node/bin:$PATH + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential git libpq-dev libyaml-dev node-gyp pkg-config python-is-python3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ + /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ + rm -rf /tmp/node-build-master + +# -j 1 avoids a QEMU bug when cross-building amd64 on Apple Silicon: https://github.com/rails/bootsnap/issues/495 +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile -j 1 --gemfile + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +RUN SECRET_KEY_BASE_DUMMY=1 \ + APP_ORIGIN=http://localhost \ + VITE_RUBY_SKIP_ASSETS_PRECOMPILE_INSTALL=true \ + ./bin/rails assets:precompile + +# The browser assets and the SSR server (public/vite-ssr/ssr.js) are both self-contained +# bundles, so the runtime needs the node binary but not node_modules. +RUN rm -rf node_modules + +# ---------- final ---------- +FROM base + +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails +# Runs the Inertia SSR server, which the inertia_ssr Puma plugin (config/puma.rb) starts. +COPY --from=build /usr/local/node/bin/node /usr/local/bin/node + +# Code stays root-owned; the app user can only write where Rails needs to. +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + mkdir -p tmp/storage && \ + chown -R rails:rails db log storage tmp +USER 1000:1000 + +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..32826feb7 --- /dev/null +++ b/Gemfile @@ -0,0 +1,87 @@ +source "https://rubygems.org" + +ruby "4.0.6" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3", ">= 8.1.3.1" +gem "json", "~> 2.9" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use postgresql as the database for Active Record +gem "pg", "~> 1.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" + +gem "inertia_rails" + +# Pagination [https://github.com/ddnexus/pagy] +gem "pagy", "~> 9.0" + +# 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 + +# Spreadsheet parsing (xlsx) [https://github.com/roo-rb/roo] +gem "roo", "~> 3.0" +# roo requires csv at runtime but does not declare it; no longer a default gem on Ruby 4.0 +gem "csv" + + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" +# Adds `content_type` / `size` validators for Active Storage attachments (not in Rails core) +gem "active_storage_validations" + +gem "faker" + +group :development, :test do + # 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 + + gem "rspec-rails", "~> 8.0" + # Runs the suite across CPU cores: `bundle exec parallel_rspec` [https://github.com/grosser/parallel_tests] + gem "parallel_tests" + gem "factory_bot_rails" + gem "dotenv" + gem "rubocop-rspec", require: false + gem "erb_lint", require: false +end + +group :test do + gem "capybara" + gem "capybara-playwright-driver" + gem "selenium-webdriver" + gem "shoulda-matchers", "~> 6.0" + gem "simplecov", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +gem "vite_rails", "~> 3.11" + +gem "aws-sdk-s3", "~> 1.231", require: false diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..5e459da77 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,709 @@ +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) + active_storage_validations (4.1.1) + activejob (>= 7.0.1) + activemodel (>= 7.0.1) + activestorage (>= 7.0.1) + activesupport (>= 7.0.1) + marcel (>= 1.0.3) + 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) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + aws-eventstream (1.4.0) + aws-partitions (1.1284.0) + aws-sdk-core (3.255.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + rexml (~> 3.4, >= 3.4.2) + aws-sdk-kms (1.131.0) + aws-sdk-core (~> 3, >= 3.255.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.231.0) + aws-sdk-core (~> 3, >= 3.255.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + base64 (0.3.0) + bcrypt (3.1.22) + bcrypt_pbkdf (1.1.2) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) + ast (~> 2.0) + erubi (~> 1.4) + parser (>= 2.4) + smart_properties + 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) + 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) + capybara-playwright-driver (0.5.10) + addressable + capybara + playwright-ruby-client (>= 1.16.0) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + csv (3.3.6) + date (3.5.1) + diff-lcs (1.6.2) + dotenv (3.2.0) + drb (2.2.3) + dry-cli (1.4.1) + ed25519 (1.4.0) + erb (6.0.7) + erb_lint (0.9.0) + activesupport + better_html (>= 2.0.1) + parser (>= 2.7.1.4) + rainbow + rubocop (>= 1) + smart_properties + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.8.0) + i18n (>= 1.8.11, < 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-arm64-darwin) + 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) + 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) + inertia_rails (3.22.0) + railties (>= 6) + 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) + jmespath (1.6.2) + json (2.21.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) + matrix (0.4.3) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0701) + 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) + mutex_m (0.3.0) + 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-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-arm64-darwin) + 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) + pagy (9.4.0) + parallel (2.2.0) + parallel_tests (5.7.0) + parallel + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + playwright-ruby-client (1.62.0) + base64 + concurrent-ruby (>= 1.1.6) + mime-types (>= 3.0) + 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 + public_suffix (7.0.5) + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-proxy (2.0.0) + rack (>= 2.0, < 4) + 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) + 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 (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.7) + 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) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + ruby-progressbar (1.13.0) + 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) + shoulda-matchers (6.5.0) + activesupport (>= 5.2.0) + simplecov (1.2.0) + smart_properties (1.17.0) + 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) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + thor (1.5.0) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) + thruster (0.1.26-arm64-darwin) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.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) + vite_rails (3.11.1) + railties (>= 5.1, < 9) + vite_ruby (~> 3.0, >= 3.2.2) + vite_ruby (3.10.5) + dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m + rack-proxy (>= 0.6.1) + zeitwerk (~> 2.2) + web-console (4.3.0) + 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 + arm64-darwin-25 + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + active_storage_validations + aws-sdk-s3 (~> 1.231) + bcrypt (~> 3.1.7) + bootsnap + brakeman + bundler-audit + capybara + capybara-playwright-driver + csv + dotenv + erb_lint + factory_bot_rails + faker + image_processing (~> 1.2) + inertia_rails + json (~> 2.9) + kamal + pagy (~> 9.0) + parallel_tests + pg (~> 1.1) + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + roo (~> 3.0) + rspec-rails (~> 8.0) + rubocop-rails-omakase + rubocop-rspec + selenium-webdriver + shoulda-matchers (~> 6.0) + simplecov + solid_cable + solid_cache + solid_queue + thruster + tzinfo-data + vite_rails (~> 3.11) + web-console + +CHECKSUMS + 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 + active_storage_validations (4.1.1) sha256=0975eb88921bf6095b4b9ea9f9cd1187e873f8952c2b77132bb362bd0f3d6a0f + 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 + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1284.0) sha256=026432da13da430a31ba7c30c0c210b35fa7d738399977f033d4a5a354de58dc + aws-sdk-core (3.255.0) sha256=2bac7fbc8796e4e2eb8e6a6edebcb880d7023a922af97b15d2a8a26c9283343f + aws-sdk-kms (1.131.0) sha256=b60d28045cd93c604142cb691b15c7ddc1e6738c4ee5a90db4f2b91f0ada1d15 + aws-sdk-s3 (1.231.0) sha256=a9fc98c6f03f0e71c7215d48ff8844d436421df7f9486301d56bdf1f368a3364 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + better_html (2.2.0) sha256=e68ab66ab09696b708333bbf35e8aa3c107500ba7892f528e2111624bdd8cf76 + 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 + capybara-playwright-driver (0.5.10) sha256=e48e572d72bc1043c644fab44985be0a1e75d7d6917dc298355581848982a2c3 + 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 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erb_lint (0.9.0) sha256=dfb5e40ad839e8d1f0d56ca85ec9a7ac4c9cd966ec281138282f35b323ca7c31 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + 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-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b + 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 + inertia_rails (3.22.0) sha256=39c20120de472015d2831fa461f8a09672c68e91c41d3d660e0b1d16b787b7b1 + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + 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 + mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 + mime-types-data (3.2026.0701) sha256=cd8811e1fb89d836499ba0582368a10ee74cef929ba956d1d5ddca045e6a730f + 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 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + 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-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + 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 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 + parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 + parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 + playwright-ruby-client (1.62.0) sha256=44eb6051ab7987f68a1288a7db7892403e59680116739987729c5fecfdb55715 + 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-proxy (2.0.0) sha256=4f1d435d82afe93bc916d1226df8be307c1b808551f0ecdb56e0b668fd5756e6 + 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 (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + 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 + rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 + 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 + shoulda-matchers (6.5.0) sha256=ef6b572b2bed1ac4aba6ab2c5ff345a24b6d055a93a3d1c3bfc86d9d499e3f44 + simplecov (1.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e + smart_properties (1.17.0) sha256=f9323f8122e932341756ddec8e0ac9ec6e238408a7661508be99439ca6d6384b + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 + thruster (0.1.26-aarch64-linux) sha256=2171cb34928c0250830008f535c4ab2ee57846cc3f5d3e96c3475f7b3de7a541 + thruster (0.1.26-arm64-darwin) sha256=40676164c433abf31313422305d9e9e9210cf941b1befad88e2428b3b8dcc36c + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + 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 + vite_rails (3.11.1) sha256=61fa4a7c9248fc28f22a05e0760810bf79f645b776c721d888a815c5d21dd338 + vite_ruby (3.10.5) sha256=e9ee92be1cb31c0b6360b02182cfe09d3ac2b7fc278db7870e3f32ae64dde49e + 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 + +RUBY VERSION + ruby 4.0.6 + +BUNDLED WITH + 4.0.16 diff --git a/KAMAL_DISCLOUSURE.md b/KAMAL_DISCLOUSURE.md new file mode 100644 index 000000000..8bf2c5dc1 --- /dev/null +++ b/KAMAL_DISCLOUSURE.md @@ -0,0 +1,70 @@ +# Kamal Deployment Status + +## Summary + +I intended to finish the Kamal 2 deployment and run it against a real server, but it was **not completed**. The repository contains Kamal configuration that is close to what a real VPS deploy needs, but that configuration has **not been verified with an actual `kamal deploy`**. + +For development, Kamal is not needed. Docker Compose is enough (see [Development](#development)). + +## Why the deploy was not completed + +- **No VPS available.** I don't have a VPS to deploy to at the moment. +- **The local rehearsal didn't fit on my machine.** I tried simulating a VPS locally with **Multipass** (an Ubuntu VM acting as the server). A VM plus Docker, the built images, and the Postgres accessory need a lot of SSD space. My Mac has a 256 GB SSD with only about 20 GB free, which wasn't enough, so I couldn't get Kamal running. + +## What is in the repository + +| File | Purpose | +|---|---| +| [config/deploy.yml](config/deploy.yml) | Production deploy config (close to final) | +| [.kamal/secrets](.kamal/secrets) | Maps secrets from the local environment and `config/master.key`. No secret values are committed. | +| [config/deploy.local.yml](config/deploy.local.yml) + [.kamal/local/](.kamal/local/) | Local rehearsal destination (`-d local`): an SSH + Docker-in-Docker container standing in for a server. Not verified. | +| [Dockerfile](Dockerfile) | Multi-stage image served through Thruster on port 80 | + +What `config/deploy.yml` defines: + +- **Roles:** `web` (Puma behind Thruster) and `job` (runs `bundle exec rake solid_queue:start` for background imports). +- **Proxy:** kamal-proxy with SSL (Let's Encrypt), `app_port: 80`, and a health check on `/up`. +- **Builder:** `amd64`, the typical VPS architecture. +- **Accessory:** `postgres:17`, bound to `127.0.0.1:5432`, with persistent data and [config/postgres/init.sql](config/postgres/init.sql) creating the queue, cache, and cable databases. +- **Environment:** `APP_ORIGIN`, `WEB_CONCURRENCY`, `RAILS_MAX_THREADS`, S3 storage for Active Storage, `DB_HOST` pointing to the Postgres accessory. +- **Secrets:** `RAILS_MASTER_KEY`, `POSTGRES_PASSWORD`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `KAMAL_REGISTRY_PASSWORD`. +- **Aliases:** `console`, `shell`, `logs`, `jobs`. + +## Remaining steps for a real VPS + +1. **Replace the placeholders** in `config/deploy.yml`: + + | Placeholder | Replace with | + |---|---| + | `192.168.0.1` (`web`, `job`, `postgres` hosts) | The VPS public IP | + | `` (`image`, `registry.username`) | Container registry account (for example Docker Hub or GHCR) | + | `users.example.com` (`proxy.host`, `APP_ORIGIN`) | The real domain | + | `umanni-users-production` (`AWS_S3_BUCKET`) | The real S3 bucket | + +2. **Set the Rails environment.** The Dockerfile sets `RAILS_ENV=development` by default, so add `RAILS_ENV: production` under `env.clear` in `config/deploy.yml`. +3. **Point DNS** for the domain (an A record) to the VPS IP. Let's Encrypt needs this to issue the SSL certificate. +4. **Provide the secrets** on the machine running Kamal: + ```bash + export KAMAL_REGISTRY_PASSWORD=... + export POSTGRES_PASSWORD=... + export AWS_ACCESS_KEY_ID=... + export AWS_SECRET_ACCESS_KEY=... + # RAILS_MASTER_KEY is read from config/master.key + ``` +5. **Deploy:** + ```bash + bin/kamal setup # first time: installs Docker on the host, boots Postgres, deploys the app + bin/kamal deploy # later deploys + bin/kamal logs # follow logs (alias) + ``` + +## Development + +For development, you only need Docker Compose: + +```bash +docker compose build +docker compose up +``` + +The app is then available at http://localhost:3000. The Solid Queue worker runs inside Puma, so spreadsheet imports work without any extra step. See [DEVELOPMENT_SETUP.md](DEVELOPMENT_SETUP.md) for the full guide: environment variables, seeding, background jobs, and troubleshooting. diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 000000000..c34d8cd18 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,130 @@ +# Performance: Ruby 4 JIT profiling + +This app can run with no JIT, with YJIT, or with ZJIT, the method-based JIT compiler that is new in Ruby 4.0. This document explains how the JIT is chosen, how `bin/jit-profile` measures the three options on this app's own code, and what the measurements say. + +## Summary + +- **Production runs YJIT** (`RUBY_JIT: yjit` in [config/deploy.yml](config/deploy.yml)). +- **YJIT** is **1.53Γ— to 2.12Γ—** faster than the interpreter on this app's hot paths. +- **ZJIT** is **1.07Γ— to 1.20Γ—** faster than the interpreter, which makes YJIT **1.40Γ— to 1.83Γ—** faster than ZJIT on Ruby 4.0.6. ZJIT also used more memory: 12.9 MB against 9.2 MB. +- **Switching is one environment variable:** `RUBY_JIT=zjit`. Re-run `bin/jit-profile` after each Ruby upgrade and switch once ZJIT is ahead. + +## Choosing the JIT: `RUBY_JIT` + +| `RUBY_JIT` | Effect | +|---|---| +| unset | Rails' default: YJIT outside development and test, no JIT in development and test | +| `yjit` | YJIT, in any environment | +| `zjit` | ZJIT, and Rails' YJIT switch is turned off | +| `off` | The interpreter only | + +How it works ([lib/ruby_jit.rb](lib/ruby_jit.rb), [config/initializers/ruby_jit.rb](config/initializers/ruby_jit.rb)): + +- **Only one JIT per process.** Ruby refuses to enable a second JIT ("Only one JIT can be enabled at the same time"). So the initializer sets `config.yjit` before Rails' own `:enable_yjit` step reads it. +- **Enabled after boot.** ZJIT is turned on in `after_initialize`, after the app has booted, the same way Rails enables YJIT. Code that only runs while booting isn't compiled. +- **Graceful fallback.** A Ruby built without ZJIT logs a warning and runs without a JIT. That includes Rubies compiled without `rustc`, such as a typical rbenv or ruby-build install. The official `ruby:4.0.6-slim` image used by the [Dockerfile](Dockerfile) has both YJIT and ZJIT. + +## The profiler: `bin/jit-profile` + +### What it measures + +Four CPU-bound slices of this app. Each builder does its setup first (which may read the schema), and the timed loop doesn't query the database. The numbers therefore compare JIT compilers rather than Postgres round trips. + +| Workload | What runs | Where it matters | +|---|---|---| +| `serialize_users` | `UserSerializer.collection` over 250 users, then `to_json` | Admin users table, shared `auth` props on every page | +| `import_rows` | 250 spreadsheet rows through `Imports::UserRow`: header aliases, cell sanitising, validation | Spreadsheet imports | +| `parse_csv` | `Imports::CsvRowSet` reading a 250-row CSV file | Spreadsheet imports | +| `render_page` | `GET /registration/new` through the whole Rack stack: middleware, routing, controller, Inertia renderer, layout | Every full page load | + +### How it measures + +- **One fresh process per JIT.** [lib/jit_profile/driver.rb](lib/jit_profile/driver.rb) starts `bin/rails runner` once for the interpreter, once for YJIT and once for ZJIT, because a process can never switch JITs. Each is chosen with `RUBY_JIT`, so it is enabled after boot, as in production. +- **Warmup, then a timed run.** Each workload is called in a loop for 2 seconds untimed, so the JIT can profile and compile it, then for 5 seconds that count ([lib/jit_profile/benchmark.rb](lib/jit_profile/benchmark.rb)). +- **Isolated workloads.** A full GC runs and the JIT's counters are reset before each one. +- **SSR and logging off.** `INERTIA_SSR_ENABLED=false` and `RAILS_LOG_LEVEL=warn` keep the Node round trip and log writes out of `render_page`. +- **A separate diagnostics pass.** ZJIT runs a fourth time, with `--zjit-stats-quiet --zjit-disable`. The stats counters slow compiled code down, so that pass is kept out of the timed numbers and used only for the diagnostics table. + +### Running it + +It needs a Ruby built with ZJIT, so run it in the Docker image. Production mode gives representative numbers: eager loading, no code reloader, quiet logs. + +```bash +docker compose up -d # once: the web container's db:prepare creates the schema +docker compose stop web # keep the running app from competing for CPU +docker compose run --rm \ + -e RAILS_ENV=production -e RACK_ENV=production \ + -e DATABASE_URL=postgres://umanni:secret@umanni-pg/umanni_users_development \ + -e VITE_RUBY_PUBLIC_OUTPUT_DIR=vite-dev \ + web sh -c 'bin/jit-profile && cat tmp/jit-profile/results.json' +``` + +- `DATABASE_URL` points production mode at the development database, which already has the schema. Only `User`'s columns are read, during setup. +- `VITE_RUBY_PUBLIC_OUTPUT_DIR=vite-dev` uses the assets the image builds. The image builds with `RAILS_ENV=development`. +- The report is printed. `results.json` is printed too, because `--rm` discards the container's `tmp/`. + +Options: `--warmup SECONDS`, `--duration SECONDS`, `--workloads serialize_users,render_page`, `--output DIR`. If a run doesn't get the JIT it asked for (say, ZJIT on a Ruby without it), the report opens with a warning instead of silently comparing the interpreter with itself. + +## Results + +Measured on 2026-09-11: +- **Machine:** Apple M1, with Docker Desktop's Linux VM given 8 CPUs and 3.8 GiB of memory. +- **Ruby:** `ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux]` +- **Rails environment:** production +- **Run length:** one run, 2 s warmup and 5 s measured per workload. + +### Throughput (iterations per second, higher is better) + +| Workload | Interpreter | YJIT | ZJIT | YJIT vs interpreter | ZJIT vs interpreter | YJIT vs ZJIT | +|---|--:|--:|--:|--:|--:|--:| +| serialize_users | 955.5 | 2,025.4 | 1,108.4 | 2.12Γ— | 1.16Γ— | 1.83Γ— | +| import_rows | 130.5 | 203.6 | 140.0 | 1.56Γ— | 1.07Γ— | 1.45Γ— | +| parse_csv | 387.7 | 593.4 | 423.0 | 1.53Γ— | 1.09Γ— | 1.40Γ— | +| render_page | 1,152.6 | 1,992.7 | 1,385.2 | 1.73Γ— | 1.20Γ— | 1.44Γ— | + +### Compilation and memory + +| | YJIT | ZJIT | +|---|--:|--:| +| Methods compiled for `render_page` | 1,056 | 1,190 | +| Compile time for `render_page` | not reported | 411 ms | +| Machine code after all four workloads | 1.8 MB | 5.1 MB | +| JIT memory after all four workloads | 9.2 MB | 12.9 MB | + +### ZJIT diagnostics (`--zjit-stats` pass) + +| Workload | Side exits | Calls not specialised | Top side exits | Top send fallbacks | +|---|--:|--:|---|---| +| serialize_users | 9,446,740 | 32.3% | guard_shape_failure 5.3M, block_param_proxy_not_iseq_or_ifunc 2.1M, guard_type_failure 2.1M | send_no_profiles 12.6M, send_not_optimized_method_type 7.3M, send_without_block_polymorphic 2.2M | +| import_rows | 6,439,944 | 26.0% | guard_type_failure 3.7M, block_param_proxy_not_iseq_or_ifunc 1.8M, guard_shape_failure 0.8M | send_without_block_polymorphic 6.9M, one_or_more_complex_arg_pass 3.1M | +| parse_csv | 19,322,883 | 9.9% | guard_shape_failure 11.3M, guard_type_failure 8.0M | send_without_block_polymorphic 1.6M, send_no_profiles 1.1M | +| render_page | 1,897,733 | 26.2% | guard_shape_failure 0.9M, guard_type_failure 0.5M, unhandled_yarv_insn 0.2M | send_without_block_polymorphic 4.8M, one_or_more_complex_arg_pass 0.9M | + +## Reading the diagnostics + +**A side exit** is compiled code handing control back to the interpreter because an assumption it was compiled under no longer holds. Every exit costs a trip out of machine code and back, so millions of them explain why ZJIT stays close to the interpreter on this app: + +- **`guard_shape_failure`:** code was specialised for objects with one instance-variable layout (shape), then received objects with another. ActiveModel and ActiveRecord objects, and the rows and hashes built while parsing a CSV, reach the same call sites with different shapes. +- **`guard_type_failure`:** a call site saw more than one class, for example a value that is sometimes a `String` and sometimes `nil`. +- **`block_param_proxy_not_iseq_or_ifunc`:** a block passed along as `&block` that is a `Symbol` or `Proc` object rather than a literal block, as in `tap(&:valid?)`. + +**Send fallbacks** are method calls ZJIT compiled as plain dynamic dispatch instead of specialising them: + +- polymorphic call sites (`send_without_block_polymorphic`) +- calls compiled before the site had profile data (`send_no_profiles`) +- method types it doesn't optimise yet (`send_not_optimized_method_type`) +- keyword and splat argument passing (`one_or_more_complex_arg_pass`) + +Most of these sites are in Rails, ActiveModel and the CSV library, not in this app's code. Rewriting app code to avoid them wouldn't pay off. The practical lever is the Ruby version. + +## Decision + +- **Keep `RUBY_JIT=yjit` in production.** On every workload it is at least 1.4Γ— faster than ZJIT, and it uses less memory. +- **Keep ZJIT one variable away.** `RUBY_JIT=zjit` needs no code change, and the specs cover both paths. +- **Re-run `bin/jit-profile` when Ruby is upgraded.** Switch `RUBY_JIT` in [config/deploy.yml](config/deploy.yml) once the ZJIT column overtakes YJIT, and keep an eye on the side-exit counts: they show whether ZJIT has started handling this app's patterns. + +## Caveats + +- **Run-to-run variation.** These numbers come from one run on a laptop's Docker VM, so expect some variation. A shorter smoke run (0.2 s warmup, 0.5 s measured) gave the same ordering on every workload. +- **CPU-bound slices, not whole requests.** Real requests also wait on Postgres, the SSR server and the network, so end-to-end gains are smaller than these numbers. +- **Slow first compile.** ZJIT's `render_page` compile time (411 ms, spread over the first requests) adds to warmup after each deploy. diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..b885d75fa --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ +web: bin/rails server +vite: bin/vite dev +jobs: bin/jobs diff --git a/README.md b/README.md index 7829f14ff..2edd82036 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,4 @@ These are mandatory. Failing any of them will invalidate your submission. # What will be assessed - 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. +- Basic Security testing against traditional vectors (SQLi, XSS, XSRF) and proper encryption of sensitive DB columns where applicable. \ No newline at end of file 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/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 000000000..e69de29bb 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/broadcasters/dashboard/broadcaster.rb b/app/broadcasters/dashboard/broadcaster.rb new file mode 100644 index 000000000..dfa1ec84b --- /dev/null +++ b/app/broadcasters/dashboard/broadcaster.rb @@ -0,0 +1,34 @@ +# app/broadcasters/dashboard/broadcaster.rb +module Dashboard + class Broadcaster + STREAM = "dashboard:stats" + LEADING_KEY = "dashboard/stats/leading" + TRAILING_KEY = "dashboard/stats/trailing" + WINDOW = 1.second + + class << self + def call + Stats.expire + claim(LEADING_KEY) ? broadcast : schedule_trailing + end + + def broadcast + ActionCable.server.broadcast(STREAM, { type: "stats.changed" }) + end + + private + + def schedule_trailing + return unless claim(TRAILING_KEY) + + Dashboard::BroadcastJob.set(wait: WINDOW).perform_later + end + + # `unless_exist` makes this an atomic claim: the first caller in the window + # gets true, everyone after it gets false until the key expires. + def claim(key) + Rails.cache.write(key, true, expires_in: WINDOW, unless_exist: true) + end + end + end +end 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..1a5986f95 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +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 = Session.find_by(id: cookies.signed[:session_id]) + session&.user || 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..88fad536c --- /dev/null +++ b/app/channels/dashboard_channel.rb @@ -0,0 +1,7 @@ +class DashboardChannel < ApplicationCable::Channel + def subscribed + return reject unless current_user&.admin? + + stream_from Dashboard::Broadcaster::STREAM + end +end diff --git a/app/channels/import_channel.rb b/app/channels/import_channel.rb new file mode 100644 index 000000000..5e9ca8727 --- /dev/null +++ b/app/channels/import_channel.rb @@ -0,0 +1,8 @@ +class ImportChannel < ApplicationCable::Channel + def subscribed + import = Import.find_by(id: params[:id]) + return reject unless import && current_user&.admin? + + stream_from Imports::ProgressBroadcaster.stream_for(import) + end +end diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb new file mode 100644 index 000000000..7c03f0144 --- /dev/null +++ b/app/controllers/admin/dashboards_controller.rb @@ -0,0 +1,11 @@ +module Admin + class DashboardsController < ApplicationController + def show + authorize! User, "index?" + + render inertia: "Admin/Dashboard", props: { + stats: -> { Dashboard::Stats.current } + } + 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..18bd521d0 --- /dev/null +++ b/app/controllers/admin/imports_controller.rb @@ -0,0 +1,44 @@ +module Admin + class ImportsController < ApplicationController + before_action :authorize_admin! + + def index + render inertia: "Admin/Imports/Index", props: { + imports: -> { ImportSerializer.collection(scope.recent.with_attached_file.limit(25)) } + } + end + + def new + render inertia: "Admin/Imports/New" + end + + def show + import = scope.find(params[:id]) + + render inertia: "Admin/Imports/Show", props: { + import: -> { ImportSerializer.new(import).as_json } + } + end + + def create + import = Current.user.imports.new(import_params) + + if import.save + ProcessImportJob.perform_later(import) + redirect_to admin_import_path(import), notice: "Import queued." + else + redirect_to new_admin_import_path, inertia: { errors: import.errors } + end + end + + private + + def scope = Import.all + + def import_params = params.expect(import: [ :file ]) + + def authorize_admin! + raise Authorization::NotAuthorizedError unless Current.user&.admin? + end + end +end diff --git a/app/controllers/admin/user_roles_controller.rb b/app/controllers/admin/user_roles_controller.rb new file mode 100644 index 000000000..34642fa41 --- /dev/null +++ b/app/controllers/admin/user_roles_controller.rb @@ -0,0 +1,17 @@ +# app/controllers/admin/user_roles_controller.rb +module Admin + class UserRolesController < ApplicationController + def update + user = policy_for(User).scope.find(params[:user_id]) + authorize! user, "toggle_role?" + + if user.update(role: user.admin? ? :member : :admin) + redirect_back fallback_location: admin_users_path, + notice: "#{user.full_name} is now #{user.role}." + else + redirect_back fallback_location: admin_users_path, + alert: user.errors.full_messages.to_sentence + 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..7ea35ffc5 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,68 @@ +module Admin + class UsersController < ApplicationController + before_action :set_user, only: %i[show edit update destroy] + + def index + authorize! User + search = UserSearch.new(scope.with_attached_avatar_image, params) + + render inertia: "Admin/Users/Index", props: { + users: UserSerializer.collection(search.records), + filters: search.to_props + } + end + + def show + authorize! @user + render inertia: "Admin/Users/Show", props: { user: UserSerializer.new(@user).as_json } + end + + def new + authorize! User + render inertia: "Admin/Users/New", props: { roles: User.roles.keys } + end + + def create + authorize! User + user = User.new(permitted_params(User.new, :user)) + + if user.save + redirect_to admin_users_path, notice: "#{user.full_name} was created." + else + redirect_to new_admin_user_path, inertia: { errors: user.errors } + end + end + + def edit + authorize! @user + render inertia: "Admin/Users/Edit", props: { + user: UserSerializer.new(@user).as_json, + roles: User.roles.keys + } + end + + def update + authorize! @user + + if @user.update(permitted_params(@user, :user)) + redirect_to admin_users_path, notice: "#{@user.full_name} was updated." + else + redirect_to edit_admin_user_path(@user), inertia: { errors: @user.errors } + end + end + + def destroy + authorize! @user + @user.destroy! + redirect_to admin_users_path, notice: "User deleted." + rescue ActiveRecord::RecordNotDestroyed + redirect_to admin_users_path, alert: @user.errors.full_messages.to_sentence + end + + private + + def scope = policy_for(User).scope + + def set_user = @user = scope.find(params[:id]) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 000000000..f6a2b6014 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,16 @@ +class ApplicationController < ActionController::Base + include Authentication + include Authorization + + # The floor is what the built stylesheet needs: Tailwind v4 relies on @property, + # color-mix() and oklch(). Rails' :modern set (Safari 17.2, Chrome 120) would also + # turn away iPhones on iOS 16.4 to 17.1 that render the app fine. + allow_browser versions: { safari: 16.4, chrome: 111, firefox: 128, opera: 97, ie: false } + + inertia_share do + { + auth: { user: Current.user && UserSerializer.new(Current.user).as_json }, + flash: { notice: flash.notice, alert: flash.alert } + } + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..e2e27c04d --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,56 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + 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_landing_url + end + + def default_landing_url + Current.user.admin? ? admin_dashboard_path : profile_path + 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 +end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb new file mode 100644 index 000000000..f02a7a20e --- /dev/null +++ b/app/controllers/concerns/authorization.rb @@ -0,0 +1,30 @@ +module Authorization + extend ActiveSupport::Concern + + class NotAuthorizedError < StandardError; end + + included do + rescue_from NotAuthorizedError, with: :deny_access + end + + private + + def authorize!(record, action = "#{action_name}?") + policy = policy_for(record) + raise NotAuthorizedError unless policy.public_send(action) + policy + end + + def policy_for(record) + klass = record.is_a?(Class) ? record : record.class + "#{klass.name}Policy".constantize.new(Current.user, record) + end + + def permitted_params(record, key) + params.expect(key => policy_for(record).permitted_attributes) + end + + def deny_access + redirect_back fallback_location: root_path, alert: "You are not authorized to do that." + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 000000000..035604765 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class HomeController < InertiaController + allow_unauthenticated_access + + def index + return render inertia: "home/index" unless authenticated? + + flash.keep + redirect_to default_landing_url + end +end diff --git a/app/controllers/inertia_controller.rb b/app/controllers/inertia_controller.rb new file mode 100644 index 000000000..2d86313af --- /dev/null +++ b/app/controllers/inertia_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class InertiaController < ApplicationController + # Share data with all Inertia responses + # see https://inertia-rails.dev/guide/shared-data + # inertia_share user: -> { Current.user&.as_json(only: [:id, :name, :email]) } +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..3712a98d8 --- /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: "Try again later." } + + def new + end + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + 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: "Password reset link is invalid or has expired." + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..da1bdf190 --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,38 @@ +# app/controllers/profiles_controller.rb + +class ProfilesController < ApplicationController + before_action :set_profile + + def show + authorize! @profile + render inertia: "Profile/Show", props: { user: UserSerializer.new(@profile).as_json } + end + + def edit + authorize! @profile + render inertia: "Profile/Edit", props: { user: UserSerializer.new(@profile).as_json } + end + + def update + authorize! @profile + + if @profile.update(permitted_params(@profile, :user)) + redirect_to profile_path, notice: "Profile updated." + else + redirect_to edit_profile_path, inertia: { errors: @profile.errors } + end + end + + def destroy + authorize! @profile + @profile.destroy! + terminate_session + redirect_to root_path, notice: "Your account has been deleted." + rescue ActiveRecord::RecordNotDestroyed + redirect_to profile_path, alert: @profile.errors.full_messages.to_sentence + end + + private + + def set_profile = @profile = Current.user +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..0f02a57a9 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,32 @@ +# app/controllers/registrations_controller.rb +class RegistrationsController < ApplicationController + allow_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to new_registration_path, alert: "Try again later." } + before_action :redirect_if_authenticated + + def new + render inertia: "Auth/Register" + end + + def create + user = User.new(registration_params.merge(role: :member)) + + if user.save + start_new_session_for user + redirect_to profile_path, notice: "Welcome, #{user.full_name}." + else + redirect_to new_registration_path, inertia: { errors: user.errors } + end + end + + private + + def registration_params + params.expect(user: %i[full_name email_address password password_confirmation]) + end + + def redirect_if_authenticated + redirect_to root_path if authenticated? + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..a10e1f4dd --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,25 @@ +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: "Try again later." } + + def new + end + + def create + if (user = User.authenticate_by(params.permit(:email_address, :password))) + start_new_session_for user + redirect_to after_authentication_url + else + redirect_to new_session_path, alert: "Invalid email or password." + end + end + + def destroy + terminate_session + + return inertia_location(new_session_path) if request.inertia? + + redirect_to new_session_path, status: :see_other + end +end 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/imports/imports/csv_row_set.rb b/app/imports/imports/csv_row_set.rb new file mode 100644 index 000000000..03a522a73 --- /dev/null +++ b/app/imports/imports/csv_row_set.rb @@ -0,0 +1,25 @@ +module Imports + class CsvRowSet < RowSet + def each + return enum_for(:each) unless block_given? + + headers = nil + index = 0 + + CSV.foreach(@path, encoding: "bom|utf-8", liberal_parsing: true) do |values| + if headers.nil? + headers = UserRow.normalize_headers(values) + raise MalformedFile, "No recognisable columns found" if headers.compact.empty? + next + end + + next if values.all?(&:blank?) + + index += 1 + yield index, UserRow.from(headers, values) + end + rescue CSV::MalformedCSVError => error + raise MalformedFile, error.message + end + end +end diff --git a/app/imports/imports/progress_broadcaster.rb b/app/imports/imports/progress_broadcaster.rb new file mode 100644 index 000000000..bb83bba38 --- /dev/null +++ b/app/imports/imports/progress_broadcaster.rb @@ -0,0 +1,9 @@ +module Imports + class ProgressBroadcaster + def self.stream_for(import) = "import:#{import.id}" + + def self.call(import) + ActionCable.server.broadcast(stream_for(import), { type: "import.changed", id: import.id }) + end + end +end diff --git a/app/imports/imports/row_set.rb b/app/imports/imports/row_set.rb new file mode 100644 index 000000000..63a8ec456 --- /dev/null +++ b/app/imports/imports/row_set.rb @@ -0,0 +1,20 @@ +module Imports + class RowSet + include Enumerable + + class MalformedFile < StandardError; end + + def self.for(import, path) + case import.format + when :xlsx then SpreadsheetRowSet.new(path) + else CsvRowSet.new(path) + end + end + + def initialize(path) + @path = path + end + + def count = @count ||= each.count + end +end diff --git a/app/imports/imports/spreadsheet_row_set.rb b/app/imports/imports/spreadsheet_row_set.rb new file mode 100644 index 000000000..41eeca789 --- /dev/null +++ b/app/imports/imports/spreadsheet_row_set.rb @@ -0,0 +1,28 @@ +module Imports + class SpreadsheetRowSet < RowSet + def each + return enum_for(:each) unless block_given? + + sheet = Roo::Excelx.new(@path) + headers = nil + index = 0 + + sheet.each_row_streaming(pad_cells: true) do |row| + values = row.map { _1&.value } + + if headers.nil? + headers = UserRow.normalize_headers(values) + raise MalformedFile, "No recognisable columns found" if headers.compact.empty? + next + end + + next if values.all?(&:blank?) + + index += 1 + yield index, UserRow.from(headers, values) + end + rescue Roo::Error, Zip::Error => error + raise MalformedFile, error.message + end + end +end diff --git a/app/imports/imports/user_importer.rb b/app/imports/imports/user_importer.rb new file mode 100644 index 000000000..a1c6482a7 --- /dev/null +++ b/app/imports/imports/user_importer.rb @@ -0,0 +1,27 @@ +module Imports + class UserImporter + Result = Data.define(:outcome, :errors) do + def created? = outcome == :created + def skipped? = outcome == :skipped + def failed? = outcome == :failed + end + + def call(row) + return Result.new(outcome: :failed, errors: row.errors.full_messages) if row.invalid? + + user = User.find_or_initialize_by(email_address: row.normalized_email) + return Result.new(outcome: :skipped, errors: []) if user.persisted? + + user.assign_attributes(row.to_user_attributes) + + if user.save + Result.new(outcome: :created, errors: []) + else + Result.new(outcome: :failed, errors: user.errors.full_messages) + end + rescue ActiveRecord::RecordNotUnique + # Lost a race with a concurrent import or signup on the unique index. + Result.new(outcome: :skipped, errors: []) + end + end +end diff --git a/app/imports/imports/user_row.rb b/app/imports/imports/user_row.rb new file mode 100644 index 000000000..e7167b99b --- /dev/null +++ b/app/imports/imports/user_row.rb @@ -0,0 +1,49 @@ +module Imports + class UserRow + include ActiveModel::Model + include ActiveModel::Attributes + + HEADER_ALIASES = { + "full_name" => :full_name, "name" => :full_name, "fullname" => :full_name, "nome" => :full_name, + "email" => :email_address, "email_address" => :email_address, "e_mail" => :email_address, + "role" => :role, "perfil" => :role, + "avatar" => :avatar_url, "avatar_url" => :avatar_url, "photo" => :avatar_url + }.freeze + + attribute :full_name, :string + attribute :email_address, :string + attribute :role, :string, default: "member" + attribute :avatar_url, :string + + validates :full_name, presence: true, length: { in: 2..120 } + validates :email_address, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :role, inclusion: { in: User.roles.keys, message: "must be admin or member" } + validates :avatar_url, format: { with: %r{\Ahttps://\S+\z} }, allow_blank: true + + def self.normalize_headers(headers) + headers.map do |header| + key = header.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_") + HEADER_ALIASES[key] + end + end + + def self.from(headers, values) + attributes = headers.zip(values).to_h.compact.except(nil) + new(attributes.transform_values { sanitize(_1) }) + end + + def self.sanitize(value) + text = value.is_a?(String) ? value : value.to_s + # Strip leading =, +, -, @ so a cell like "=cmd|..." cannot become a live + # formula if this data is ever re-exported to a spreadsheet. + text.squish.sub(/\A[=+\-@\t\r]+/, "") + end + + def normalized_email = email_address.to_s.strip.downcase + + def to_user_attributes + { full_name:, email_address: normalized_email, role:, avatar_url: avatar_url.presence, + password: SecureRandom.base58(24) } + end + end +end diff --git a/app/javascript/assets/inertia.svg b/app/javascript/assets/inertia.svg new file mode 100644 index 000000000..61ec585c3 --- /dev/null +++ b/app/javascript/assets/inertia.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/assets/rails.svg b/app/javascript/assets/rails.svg new file mode 100644 index 000000000..92f66e7c8 --- /dev/null +++ b/app/javascript/assets/rails.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/javascript/assets/react.svg b/app/javascript/assets/react.svg new file mode 100644 index 000000000..ae3e3f227 --- /dev/null +++ b/app/javascript/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/assets/vite_ruby.svg b/app/javascript/assets/vite_ruby.svg new file mode 100644 index 000000000..c4d427016 --- /dev/null +++ b/app/javascript/assets/vite_ruby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/components/Avatar.tsx b/app/javascript/components/Avatar.tsx new file mode 100644 index 000000000..6568b99b8 --- /dev/null +++ b/app/javascript/components/Avatar.tsx @@ -0,0 +1,37 @@ +import type { User } from '@/types' + +function initials(fullName: string) { + return fullName + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join('') +} + +type Props = { + user: User + size?: 'sm' | 'md' +} + +const SIZES = { + sm: 'h-8 w-8 text-xs', + md: 'h-10 w-10 text-sm', +} as const + +export default function Avatar({ user, size = 'sm' }: Props) { + const base = `${SIZES[size]} shrink-0 rounded-full object-cover` + + if (user.avatar_url) { + return + } + + return ( + + {initials(user.full_name)} + + ) +} diff --git a/app/javascript/components/Field.tsx b/app/javascript/components/Field.tsx new file mode 100644 index 000000000..245e54bfb --- /dev/null +++ b/app/javascript/components/Field.tsx @@ -0,0 +1,50 @@ +import { Children, cloneElement, useId, type ReactElement, type ReactNode } from 'react' + +type Props = { + label: string + /** Rails sends every error as an array of messages (see `errorValueType` in types/globals.d.ts). */ + error?: string[] | string + hint?: string + children: ReactNode +} + +type Control = ReactElement<{ + id?: string + 'aria-invalid'?: boolean + 'aria-describedby'?: string +}> + +export default function Field({ label, error, hint, children }: Props) { + const id = useId() + const messages = error === undefined ? [] : [error].flat() + const describedBy = [hint && `${id}-hint`, messages.length && `${id}-error`] + .filter(Boolean) + .join(' ') + + const control = cloneElement(Children.only(children) as Control, { + id, + 'aria-invalid': messages.length > 0 || undefined, + 'aria-describedby': describedBy || undefined, + }) + + return ( +
+ +
{control}
+ + {hint && ( +

+ {hint} +

+ )} + + {messages.length > 0 && ( +

+ {messages.join(', ')} +

+ )} +
+ ) +} diff --git a/app/javascript/components/ImportStatusBadge.tsx b/app/javascript/components/ImportStatusBadge.tsx new file mode 100644 index 000000000..ddcfbefd3 --- /dev/null +++ b/app/javascript/components/ImportStatusBadge.tsx @@ -0,0 +1,20 @@ +import type { ImportStatus } from '@/types' + +const STYLES: Record = { + pending: 'bg-slate-100 text-slate-600 ring-slate-200', + parsing: 'bg-amber-50 text-amber-700 ring-amber-200', + processing: 'bg-amber-50 text-amber-700 ring-amber-200', + completed: 'bg-emerald-50 text-emerald-700 ring-emerald-200', + failed: 'bg-red-50 text-red-700 ring-red-200', + cancelled: 'bg-slate-100 text-slate-500 ring-slate-200', +} + +export default function ImportStatusBadge({ status }: { status: ImportStatus }) { + return ( + + {status} + + ) +} diff --git a/app/javascript/components/LocalTime.tsx b/app/javascript/components/LocalTime.tsx new file mode 100644 index 000000000..94729f9ae --- /dev/null +++ b/app/javascript/components/LocalTime.tsx @@ -0,0 +1,14 @@ +import { useLocaleFormat } from '@/hooks/useLocaleFormat' + +type Props = { + /** ISO 8601 timestamp. */ + dateTime: string + options: Intl.DateTimeFormatOptions +} + +/** A