From db57a7ba0f45e8c27c2761c9af010cce2f808905 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:04:20 +0100 Subject: [PATCH 001/318] Initial commit: Move fizzy-saas to separate repository --- saas/.github/dependabot.yml | 12 ++ saas/.github/workflows/ci.yml | 69 ++++++++++ saas/.gitignore | 10 ++ saas/.kamal/hooks/post-deploy | 17 +++ saas/.kamal/hooks/pre-connect | 82 +++++++++++ saas/.rubocop.yml | 8 ++ saas/Gemfile | 6 + saas/Gemfile.lock | 69 ++++++++++ saas/README.md | 28 ++++ saas/Rakefile | 8 ++ saas/app/assets/images/fizzy/saas/.keep | 0 .../stylesheets/fizzy/saas/application.css | 15 +++ saas/app/controllers/concerns/.keep | 0 .../signup/completions_controller.rb | 24 ++++ saas/app/models/concerns/.keep | 0 saas/app/models/signup.rb | 127 ++++++++++++++++++ .../models/signup/account_name_generator.rb | 53 ++++++++ saas/app/models/subscription.rb | 13 ++ .../layouts/fizzy/saas/application.html.erb | 17 +++ .../app/views/signup/completions/new.html.erb | 30 +++++ saas/app/views/signup/new.html.erb | 28 ++++ saas/bin/rails | 14 ++ saas/bin/rubocop | 8 ++ saas/config/database.yml | 100 ++++++++++++++ saas/config/deploy.beta.yml | 33 +++++ saas/config/deploy.dhh.yml | 12 ++ saas/config/deploy.production.yml | 69 ++++++++++ saas/config/deploy.staging.yml | 69 ++++++++++ saas/config/deploy.yml | 35 +++++ saas/config/routes.rb | 9 ++ saas/fizzy-saas.gemspec | 27 ++++ saas/lib/fizzy/saas.rb | 7 + saas/lib/fizzy/saas/engine.rb | 19 +++ saas/lib/fizzy/saas/version.rb | 5 + saas/lib/tasks/fizzy/saas_tasks.rake | 4 + saas/test/controllers/.keep | 0 .../signups/completions_controller_test.rb | 43 ++++++ saas/test/fixtures/files/.keep | 0 saas/test/helpers/.keep | 0 saas/test/integration/.keep | 0 saas/test/mailers/.keep | 0 saas/test/models/.keep | 0 .../signup/account_name_generator_test.rb | 61 +++++++++ saas/test/models/signup_test.rb | 53 ++++++++ saas/test/test_helper.rb | 9 ++ 45 files changed, 1193 insertions(+) create mode 100644 saas/.github/dependabot.yml create mode 100644 saas/.github/workflows/ci.yml create mode 100644 saas/.gitignore create mode 100755 saas/.kamal/hooks/post-deploy create mode 100755 saas/.kamal/hooks/pre-connect create mode 100644 saas/.rubocop.yml create mode 100644 saas/Gemfile create mode 100644 saas/Gemfile.lock create mode 100644 saas/README.md create mode 100644 saas/Rakefile create mode 100644 saas/app/assets/images/fizzy/saas/.keep create mode 100644 saas/app/assets/stylesheets/fizzy/saas/application.css create mode 100644 saas/app/controllers/concerns/.keep create mode 100644 saas/app/controllers/signup/completions_controller.rb create mode 100644 saas/app/models/concerns/.keep create mode 100644 saas/app/models/signup.rb create mode 100644 saas/app/models/signup/account_name_generator.rb create mode 100644 saas/app/models/subscription.rb create mode 100644 saas/app/views/layouts/fizzy/saas/application.html.erb create mode 100644 saas/app/views/signup/completions/new.html.erb create mode 100644 saas/app/views/signup/new.html.erb create mode 100755 saas/bin/rails create mode 100755 saas/bin/rubocop create mode 100644 saas/config/database.yml create mode 100644 saas/config/deploy.beta.yml create mode 100644 saas/config/deploy.dhh.yml create mode 100644 saas/config/deploy.production.yml create mode 100644 saas/config/deploy.staging.yml create mode 100644 saas/config/deploy.yml create mode 100644 saas/config/routes.rb create mode 100644 saas/fizzy-saas.gemspec create mode 100644 saas/lib/fizzy/saas.rb create mode 100644 saas/lib/fizzy/saas/engine.rb create mode 100644 saas/lib/fizzy/saas/version.rb create mode 100644 saas/lib/tasks/fizzy/saas_tasks.rake create mode 100644 saas/test/controllers/.keep create mode 100644 saas/test/controllers/signups/completions_controller_test.rb create mode 100644 saas/test/fixtures/files/.keep create mode 100644 saas/test/helpers/.keep create mode 100644 saas/test/integration/.keep create mode 100644 saas/test/mailers/.keep create mode 100644 saas/test/models/.keep create mode 100644 saas/test/models/signup/account_name_generator_test.rb create mode 100644 saas/test/models/signup_test.rb create mode 100644 saas/test/test_helper.rb diff --git a/saas/.github/dependabot.yml b/saas/.github/dependabot.yml new file mode 100644 index 000000000..83610cfa4 --- /dev/null +++ b/saas/.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/saas/.github/workflows/ci.yml b/saas/.github/workflows/ci.yml new file mode 100644 index 000000000..ef5e97c73 --- /dev/null +++ b/saas/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + env: + RUBY_VERSION: ruby-3.4.5 + RUBOCOP_CACHE_ROOT: tmp/rubocop + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + + - name: Prepare RuboCop cache + uses: actions/cache@v4 + env: + DEPENDENCIES_HASH: ${{ hashFiles('**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} + with: + path: ${{ env.RUBOCOP_CACHE_ROOT }} + key: rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} + restore-keys: | + rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}- + + - name: Lint code for consistent style + run: bin/rubocop -f github + + test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby-3.4.5 + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/saas/.gitignore b/saas/.gitignore new file mode 100644 index 000000000..a3ee5aad3 --- /dev/null +++ b/saas/.gitignore @@ -0,0 +1,10 @@ +/.bundle/ +/doc/ +/log/*.log +/pkg/ +/tmp/ +/test/dummy/db/*.sqlite3 +/test/dummy/db/*.sqlite3-* +/test/dummy/log/*.log +/test/dummy/storage/ +/test/dummy/tmp/ diff --git a/saas/.kamal/hooks/post-deploy b/saas/.kamal/hooks/post-deploy new file mode 100755 index 000000000..871506078 --- /dev/null +++ b/saas/.kamal/hooks/post-deploy @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +MESSAGE="$KAMAL_PERFORMER deployed $KAMAL_SERVICE_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +bin/notify_dash_of_deployment "$MESSAGE" $KAMAL_VERSION $KAMAL_PERFORMER $CURRENT_BRANCH $KAMAL_DESTINATION $KAMAL_RUNTIME + +if [[ $CURRENT_BRANCH == "main" && $KAMAL_DESTINATION == "production" ]]; then + gh release create $KAMAL_SERVICE_VERSION --target $KAMAL_VERSION --generate-notes 2> /dev/null || true + + RELEASE_URL=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .url) + RELEASE_BODY=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .body) + + bin/broadcast_to_bc "$MESSAGE "$'\n'"$RELEASE_URL "$'\n'"$RELEASE_BODY" +else + bin/broadcast_to_bc "$MESSAGE" +fi diff --git a/saas/.kamal/hooks/pre-connect b/saas/.kamal/hooks/pre-connect new file mode 100755 index 000000000..5f42f2cf2 --- /dev/null +++ b/saas/.kamal/hooks/pre-connect @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# Validate hostnames are FQDNs ending in -int.37signals.com +if command -v yq >/dev/null 2>&1; then + declare -A SUGGESTIONS + while IFS= read -r host; do + if [[ ! $host =~ -int\.37signals\.com$ ]]; then + if [[ $host =~ -4[0-9]{2}$ ]]; then + SUGGESTIONS["$host"]="$host.df-ams-int.37signals.com" + elif [[ $host =~ -1[0-9]{2}$ ]]; then + SUGGESTIONS["$host"]="$host.df-iad-int.37signals.com" + else + SUGGESTIONS["$host"]="$host.sc-chi-int.37signals.com" + fi + fi + done < <(bin/kamal config -d "${KAMAL_DESTINATION:-production}" 2>/dev/null | yq -r '.":hosts"[]') + + if [ ${#SUGGESTIONS[@]} -gt 0 ]; then + echo "Unqualified hostnames found in config/deploy.${KAMAL_DESTINATION:-production}.yml:" >&2 + echo "" >&2 + echo "Update to use fully-qualified hostnames:" >&2 + for host in "${!SUGGESTIONS[@]}"; do + echo " $host → ${SUGGESTIONS[$host]}" >&2 + done + exit 1 + fi +fi + +# Verify Tailscale connection and SSH authentication before deploying. +tailscale_cmd() { + if command -v tailscale >/dev/null 2>&1; then + tailscale "$@" + elif [ -f "/Applications/Tailscale.app/Contents/MacOS/Tailscale" ]; then + env TAILSCALE_BE_CLI=1 /Applications/Tailscale.app/Contents/MacOS/Tailscale "$@" + else + return 1 + fi +} + +on_tailscale() { + tailscale_cmd status --json 2>/dev/null | jq -e '.Self.Online' >/dev/null 2>&1 +} + +# Check Tailscale connection +if ! on_tailscale; then + echo "" >&2 + echo "You must be connected to Tailscale to deploy." >&2 + echo "" >&2 + echo "→ Connect to Tailscale and try again" >&2 + echo "" >&2 + exit 1 +fi + +# Verify SSH access +echo "Deploying via Tailscale. Verifying SSH access…" >&2 + +TEST_HOST="fizzy-app-101" + +SSH_OUTPUT=$(ssh -o ConnectTimeout=5 "app@$TEST_HOST" true 2>&1) +SSH_EXIT=$? + +echo "$SSH_OUTPUT" >&2 + +if echo "$SSH_OUTPUT" | grep -q "Permission denied"; then + GITHUB_USER=$(gh api user 2>/dev/null | jq -r '.login // "unknown"') + GITHUB_KEYS_URL="https://github.com/${GITHUB_USER}.keys" + + echo "" >&2 + echo "ERROR: SSH authentication failed" >&2 + echo "" >&2 + echo "You must deploy with an SSH key that's on your GitHub account." >&2 + echo "" >&2 + echo "→ Verify your public key is at $GITHUB_KEYS_URL" >&2 + echo " Add it at https://github.com/settings/keys if not" >&2 + echo "" >&2 + echo "Note that SSH keys are pulled from GitHub every 5 minutes, so if you've" >&2 + echo "just added a new key to GitHub, try again in five." >&2 + echo "" >&2 + exit 1 +fi + +exit $SSH_EXIT diff --git a/saas/.rubocop.yml b/saas/.rubocop.yml new file mode 100644 index 000000000..f9d86d4a5 --- /dev/null +++ b/saas/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/saas/Gemfile b/saas/Gemfile new file mode 100644 index 000000000..396a3192e --- /dev/null +++ b/saas/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" +git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } + +# 37id and Queenbee integration +gem "queenbee", bc: "queenbee-plugin", ref: "eb01c697de1ad028afc65cc7d9b5345a7a8e849f" +gem "activeresource", require: "active_resource" # needed by queenbee diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock new file mode 100644 index 000000000..5d83987cd --- /dev/null +++ b/saas/Gemfile.lock @@ -0,0 +1,69 @@ +GIT + remote: https://github.com/basecamp/queenbee-plugin + revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f + ref: eb01c697de1ad028afc65cc7d9b5345a7a8e849f + specs: + queenbee (3.2.0) + activeresource + builder + rexml + +GEM + remote: https://rubygems.org/ + specs: + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activemodel-serializers-xml (1.0.3) + activemodel (>= 5.0.0.a) + activesupport (>= 5.0.0.a) + builder (~> 3.1) + activeresource (6.1.4) + activemodel (>= 6.0) + activemodel-serializers-xml (~> 1.0) + activesupport (>= 6.0) + activesupport (8.0.2.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.2.3) + builder (3.3.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + drb (2.2.3) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + logger (1.7.0) + minitest (5.25.5) + rexml (3.4.4) + securerandom (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.0.3) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + activeresource + queenbee! + +BUNDLED WITH + 2.7.0 diff --git a/saas/README.md b/saas/README.md new file mode 100644 index 000000000..ecaa3ede6 --- /dev/null +++ b/saas/README.md @@ -0,0 +1,28 @@ +# Fizzy::Saas +Short description and motivation. + +## Usage +How to use my plugin. + +## Installation +Add this line to your application's Gemfile: + +```ruby +gem "fizzy-saas" +``` + +And then execute: +```bash +$ bundle +``` + +Or install it yourself as: +```bash +$ gem install fizzy-saas +``` + +## Contributing +Contribution directions go here. + +## License +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/saas/Rakefile b/saas/Rakefile new file mode 100644 index 000000000..e7793b5c1 --- /dev/null +++ b/saas/Rakefile @@ -0,0 +1,8 @@ +require "bundler/setup" + +APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) +load "rails/tasks/engine.rake" + +load "rails/tasks/statistics.rake" + +require "bundler/gem_tasks" diff --git a/saas/app/assets/images/fizzy/saas/.keep b/saas/app/assets/images/fizzy/saas/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/app/assets/stylesheets/fizzy/saas/application.css b/saas/app/assets/stylesheets/fizzy/saas/application.css new file mode 100644 index 000000000..0ebd7fe82 --- /dev/null +++ b/saas/app/assets/stylesheets/fizzy/saas/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/saas/app/controllers/concerns/.keep b/saas/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/app/controllers/signup/completions_controller.rb b/saas/app/controllers/signup/completions_controller.rb new file mode 100644 index 000000000..d7f09c086 --- /dev/null +++ b/saas/app/controllers/signup/completions_controller.rb @@ -0,0 +1,24 @@ +class Signup::CompletionsController < ApplicationController + layout "public" + + disallow_account_scope + + def new + @signup = Signup.new(identity: Current.identity) + end + + def create + @signup = Signup.new(signup_params) + + if @signup.complete + redirect_to landing_url(script_name: @signup.account.slug) + else + render :new, status: :unprocessable_entity + end + end + + private + def signup_params + params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity) + end +end diff --git a/saas/app/models/concerns/.keep b/saas/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/app/models/signup.rb b/saas/app/models/signup.rb new file mode 100644 index 000000000..ca76f5b08 --- /dev/null +++ b/saas/app/models/signup.rb @@ -0,0 +1,127 @@ +class Signup + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + attr_accessor :full_name, :email_address, :identity + attr_reader :queenbee_account, :account, :user + + with_options on: :completion do + validates_presence_of :full_name, :identity + end + + def initialize(...) + @full_name = nil + @email_address = nil + @account = nil + @user = nil + @queenbee_account = nil + @identity = nil + + super + + @email_address = @identity.email_address if @identity + end + + def create_identity + @identity = Identity.find_or_create_by!(email_address: email_address) + @identity.send_magic_link + end + + def complete + if valid?(:completion) + begin + create_queenbee_account + create_account + + true + rescue => error + destroy_account + destroy_queenbee_account + + errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.") + Rails.error.report(error, severity: :error) + Rails.logger.error error + Rails.logger.error error.backtrace.join("\n") + + false + end + else + false + end + end + + private + def create_queenbee_account + @account_name = AccountNameGenerator.new(identity: identity, name: full_name).generate + @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) + @tenant = queenbee_account.id.to_s + end + + def destroy_queenbee_account + @queenbee_account&.cancel + @queenbee_account = nil + end + + def create_account + @account = Account.create_with_admin_user( + account: { + external_account_id: @tenant, + name: @account_name + }, + owner: { + name: full_name, + identity: identity + } + ) + @user = @account.users.find_by!(role: :admin) + @account.setup_customer_template + end + + def destroy_account + @account&.destroy! + + @user = nil + @account = nil + @tenant = nil + end + + def queenbee_account_attributes + {}.tap do |attributes| + attributes[:product_name] = "fizzy" + attributes[:name] = @account_name + attributes[:owner_name] = full_name + attributes[:owner_email] = email_address + + attributes[:trial] = true + attributes[:subscription] = subscription_attributes + attributes[:remote_request] = request_attributes + + # # TODO: Terms of Service + # attributes[:terms_of_service] = true + + # We've confirmed the email + attributes[:auto_allow] = true + + # Tell Queenbee to skip the request to create a local account. We've created it ourselves. + attributes[:skip_remote] = true + end + end + + def subscription_attributes + subscription = FreeV1Subscription + + {}.tap do |attributes| + attributes[:name] = subscription.to_param + attributes[:price] = subscription.price + end + end + + def request_attributes + {}.tap do |attributes| + attributes[:remote_address] = Current.ip_address + attributes[:user_agent] = Current.user_agent + attributes[:referrer] = Current.referrer + end + end +end diff --git a/saas/app/models/signup/account_name_generator.rb b/saas/app/models/signup/account_name_generator.rb new file mode 100644 index 000000000..a6844d3a3 --- /dev/null +++ b/saas/app/models/signup/account_name_generator.rb @@ -0,0 +1,53 @@ +class Signup::AccountNameGenerator + SUFFIX = "Fizzy".freeze + + attr_reader :identity, :name + + def initialize(identity:, name:) + @identity = identity + @name = name + end + + def generate + next_index = current_index + 1 + + if next_index == 1 + "#{prefix} #{SUFFIX}" + else + "#{prefix} #{next_index.ordinalize} #{SUFFIX}" + end + end + + private + def current_index + existing_indices.max || 0 + end + + def existing_indices + Current.without_account do + identity.accounts.filter_map do |account| + if account.name.match?(first_account_name_regex) + 1 + elsif match = account.name.match(nth_account_name_regex) + match[1].to_i + end + end + end + end + + def first_account_name_regex + @first_account_name_regex ||= /\A#{prefix}\s+#{SUFFIX}\Z/i + end + + def nth_account_name_regex + @nth_account_name_regex ||= /\A#{prefix}\s+(1st|2nd|3rd|\d+th)\s+#{SUFFIX}/i + end + + def prefix + @prefix ||= "#{first_name}'s" + end + + def first_name + name.strip.split(" ", 2).first + end +end diff --git a/saas/app/models/subscription.rb b/saas/app/models/subscription.rb new file mode 100644 index 000000000..5efb1a7fa --- /dev/null +++ b/saas/app/models/subscription.rb @@ -0,0 +1,13 @@ +class Subscription < Queenbee::Subscription + SHORT_NAMES = %w[ FreeV1 ] + + def self.short_name + name.demodulize + end + + class FreeV1 < Subscription + property :proper_name, "Free Subscription" + property :price, 0 + property :frequency, "yearly" + end +end diff --git a/saas/app/views/layouts/fizzy/saas/application.html.erb b/saas/app/views/layouts/fizzy/saas/application.html.erb new file mode 100644 index 000000000..144b37838 --- /dev/null +++ b/saas/app/views/layouts/fizzy/saas/application.html.erb @@ -0,0 +1,17 @@ + + + + Fizzy saas + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%= stylesheet_link_tag "fizzy/saas/application", media: "all" %> + + + +<%= yield %> + + + diff --git a/saas/app/views/signup/completions/new.html.erb b/saas/app/views/signup/completions/new.html.erb new file mode 100644 index 000000000..aee8f4586 --- /dev/null +++ b/saas/app/views/signup/completions/new.html.erb @@ -0,0 +1,30 @@ +<% @page_title = "Complete your sign-up" %> + +
"> +

<%= @page_title %>

+ + <%= form_with model: @signup, url: saas.signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %> + <%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true %> + +

You’re one step away. Just enter your name to get your own Fizzy account.

+ + <% if @signup.errors.any? %> +
+
    + <% @signup.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/saas/app/views/signup/new.html.erb b/saas/app/views/signup/new.html.erb new file mode 100644 index 000000000..93166dd2f --- /dev/null +++ b/saas/app/views/signup/new.html.erb @@ -0,0 +1,28 @@ +<% @page_title = "Sign up for Fizzy" %> + +
"> +

Sign up

+ + <%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %> + <%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %> + + <% if @signup.errors.any? %> +
+
    + <% @signup.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/saas/bin/rails b/saas/bin/rails new file mode 100755 index 000000000..42a0e5bce --- /dev/null +++ b/saas/bin/rails @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path("..", __dir__) +ENGINE_PATH = File.expand_path("../lib/fizzy/saas/engine", __dir__) +APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) + +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) + +require "rails/all" +require "rails/engine/commands" diff --git a/saas/bin/rubocop b/saas/bin/rubocop new file mode 100755 index 000000000..40330c0ff --- /dev/null +++ b/saas/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/saas/config/database.yml b/saas/config/database.yml new file mode 100644 index 000000000..3c68fd081 --- /dev/null +++ b/saas/config/database.yml @@ -0,0 +1,100 @@ +<% + if ENV["MIGRATE"].present? + mysql_app_user_key = "MYSQL_ALTER_USER" + mysql_app_password_key = "MYSQL_ALTER_PASSWORD" + else + mysql_app_user_key = "MYSQL_APP_USER" + mysql_app_password_key = "MYSQL_APP_PASSWORD" + end + + mysql_app_user = ENV[mysql_app_user_key] + mysql_app_password = ENV[mysql_app_password_key] +%> + +default: &default + adapter: trilogy + host: <%= ENV.fetch "FIZZY_DB_HOST", "127.0.0.1" %> + port: <%= ENV.fetch "FIZZY_DB_PORT", 3306 %> + pool: 50 + timeout: 5000 + variables: + transaction_isolation: READ-COMMITTED + +development: + primary: + <<: *default + database: fizzy_development + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: + <<: *default + database: fizzy_development + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: true + cable: + <<: *default + database: development_cable + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/cable_migrate + cache: + <<: *default + database: development_cache + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/cache_migrate + queue: + <<: *default + database: development_queue + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/queue_migrate + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + primary: + <<: *default + database: fizzy_test + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: + <<: *default + database: fizzy_test + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: true + +production: &production + primary: + <<: *default + database: fizzy_production + host: <%= ENV["MYSQL_DATABASE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + replica: + <<: *default + database: fizzy_production + host: <%= ENV["MYSQL_DATABASE_REPLICA_HOST"] %> + username: <%= ENV["MYSQL_READONLY_USER"] %> + password: <%= ENV["MYSQL_READONLY_PASSWORD"] %> + replica: true + cable: + <<: *default + database: fizzy_solidcable_production + host: <%= ENV["MYSQL_SOLID_CABLE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: db/cable_migrate + queue: + <<: *default + database: fizzy_solidqueue_production + host: <%= ENV["MYSQL_SOLID_QUEUE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: db/queue_migrate + cache: + <<: *default + database: fizzy_solidcache_production + host: <%= ENV["MYSQL_SOLID_CACHE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: db/cache_migrate + +beta: *production +staging: *production diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml new file mode 100644 index 000000000..f1db401c7 --- /dev/null +++ b/saas/config/deploy.beta.yml @@ -0,0 +1,33 @@ +servers: + web: + hosts: + - fizzy-beta-app-01.sc-chi-int.37signals.com + - fizzy-beta-app-101.df-iad-int.37signals.com + labels: + otel_scrape_enabled: true + jobs: + hosts: + - fizzy-beta-app-01.sc-chi-int.37signals.com + labels: + otel_scrape_enabled: true + +proxy: + ssl: false + +ssh: + user: app + +env: + clear: + RAILS_ENV: beta + +accessories: + load-balancer: + image: basecamp/kamal-proxy:lb + host: fizzy-beta-lb-01.sc-chi-int.37signals.com + options: + publish: + - 80:80 + - 443:443 + volumes: + - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/saas/config/deploy.dhh.yml b/saas/config/deploy.dhh.yml new file mode 100644 index 000000000..05bf874d4 --- /dev/null +++ b/saas/config/deploy.dhh.yml @@ -0,0 +1,12 @@ +servers: + web: + hosts: + - test-1 + jobs: + hosts: + - test-1 +env: + clear: + ARTENANT: test + +proxy: false diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml new file mode 100644 index 000000000..285dab819 --- /dev/null +++ b/saas/config/deploy.production.yml @@ -0,0 +1,69 @@ +servers: + web: + hosts: + - fizzy-app-01.sc-chi-int.37signals.com: sc_chi + - fizzy-app-02.sc-chi-int.37signals.com: sc_chi + - fizzy-app-101.df-iad-int.37signals.com: df_iad + - fizzy-app-102.df-iad-int.37signals.com: df_iad + - fizzy-app-401.df-ams-int.37signals.com: df_ams + - fizzy-app-402.df-ams-int.37signals.com: df_ams + labels: + otel_scrape_enabled: true + jobs: + hosts: + - fizzy-jobs-101.df-iad-int.37signals.com: df_iad + - fizzy-jobs-102.df-iad-int.37signals.com: df_iad + labels: + otel_scrape_enabled: true + +proxy: + ssl: false + +ssh: + user: app + +env: + clear: + RAILS_ENV: production + MYSQL_DATABASE_HOST: fizzy-mysql-primary + MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica + MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary + MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary + secret: + - RAILS_MASTER_KEY + - MYSQL_ALTER_PASSWORD + - MYSQL_ALTER_USER + - MYSQL_APP_PASSWORD + - MYSQL_APP_USER + - MYSQL_READONLY_PASSWORD + - MYSQL_READONLY_USER + tags: + sc_chi: + MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com + df_iad: + MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-101.df-iad-int.37signals.com + PRIMARY_DATACENTER: true + df_ams: + MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-401.df-ams-int.37signals.com + + +accessories: + load-balancer: + image: basecamp/kamal-proxy:lb + hosts: + - fizzy-lb-101.df-iad-int.37signals.com + - fizzy-lb-01.sc-chi-int.37signals.com + - fizzy-lb-401.df-ams-int.37signals.com + labels: + otel_role: load-balancer + otel_service: fizzy-load-balancer + otel_scrape_enabled: true + options: + publish: + - 80:80 + - 443:443 + # NFS mount for certificates + # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 + mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-production-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" + volumes: + - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml new file mode 100644 index 000000000..3163c7a7b --- /dev/null +++ b/saas/config/deploy.staging.yml @@ -0,0 +1,69 @@ +servers: + web: + hosts: + - fizzy-staging-app-101.df-iad-int.37signals.com: df_iad + - fizzy-staging-app-102.df-iad-int.37signals.com: df_iad + - fizzy-staging-app-01.sc-chi-int.37signals.com: sc_chi + - fizzy-staging-app-02.sc-chi-int.37signals.com: sc_chi + - fizzy-staging-app-401.df-ams-int.37signals.com: df_ams + - fizzy-staging-app-402.df-ams-int.37signals.com: df_ams + labels: + otel_scrape_enabled: true + jobs: + hosts: + - fizzy-staging-jobs-101.df-iad-int.37signals.com: df_iad + - fizzy-staging-jobs-102.df-iad-int.37signals.com: df_iad + labels: + otel_scrape_enabled: true + +proxy: + ssl: false + +ssh: + user: app + +env: + clear: + RAILS_ENV: staging + MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary + MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica + MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary + MYSQL_SOLID_QUEUE_HOST: fizzy-staging-mysql-primary + secret: + - RAILS_MASTER_KEY + - MYSQL_ALTER_PASSWORD + - MYSQL_ALTER_USER + - MYSQL_APP_PASSWORD + - MYSQL_APP_USER + - MYSQL_READONLY_PASSWORD + - MYSQL_READONLY_USER + tags: + sc_chi: + MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com + df_iad: + MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-101.df-iad-int.37signals.com + PRIMARY_DATACENTER: true + df_ams: + MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-401.df-ams-int.37signals.com + + +accessories: + load-balancer: + image: basecamp/kamal-proxy:lb + hosts: + - fizzy-staging-lb-01.sc-chi-int.37signals.com + - fizzy-staging-lb-101.df-iad-int.37signals.com + - fizzy-staging-lb-401.df-ams-int.37signals.com + labels: + otel_role: load-balancer + otel_service: fizzy-load-balancer + otel_scrape_enabled: true + options: + publish: + - 80:80 + - 443:443 + # NFS mount for certificates + # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 + mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-staging-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" + volumes: + - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/saas/config/deploy.yml b/saas/config/deploy.yml new file mode 100644 index 000000000..ea3ca3c49 --- /dev/null +++ b/saas/config/deploy.yml @@ -0,0 +1,35 @@ +service: fizzy +image: basecamp/fizzy +asset_path: /rails/public/assets +hooks_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal", "hooks") %> + +servers: + jobs: + cmd: bin/jobs + +volumes: + - fizzy:/rails/storage + +proxy: + ssl: true + +registry: + server: registry.37signals.com + username: robot$harbor-bot + password: + - BASECAMP_REGISTRY_PASSWORD + +builder: + arch: amd64 + secrets: + - GITHUB_TOKEN + remote: ssh://app@docker-builder-102 + local: <%= ENV.fetch("KAMAL_BUILDER_LOCAL", "true") %> + +env: + secret: + - RAILS_MASTER_KEY + +aliases: + console: app exec -i --reuse "bin/rails console" + ssh: app exec -i --reuse /bin/bash diff --git a/saas/config/routes.rb b/saas/config/routes.rb new file mode 100644 index 000000000..2b9639edb --- /dev/null +++ b/saas/config/routes.rb @@ -0,0 +1,9 @@ +Fizzy::Saas::Engine.routes.draw do + get "/signup/new", to: redirect("/session/new") + + namespace :signup do + resource :completion, only: %i[ new create ] + end + + Queenbee.routes(self) +end diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec new file mode 100644 index 000000000..f1b28de00 --- /dev/null +++ b/saas/fizzy-saas.gemspec @@ -0,0 +1,27 @@ +require_relative "lib/fizzy/saas/version" + +Gem::Specification.new do |spec| + spec.name = "fizzy-saas" + spec.version = Fizzy::Saas::VERSION + spec.authors = [ "Mike Dalessio" ] + spec.email = [ "mike@37signals.com" ] + spec.homepage = "TODO" + spec.summary = "TODO: Summary of Fizzy::Saas." + spec.description = "TODO: Description of Fizzy::Saas." + + # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host" + # to allow pushing to a single host or delete this section to allow pushing to any host. + spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." + spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] + end + + spec.add_dependency "rails", ">= 8.1.0.beta1" + spec.add_dependency "queenbee" + spec.add_dependency "rails_structured_logging" +end diff --git a/saas/lib/fizzy/saas.rb b/saas/lib/fizzy/saas.rb new file mode 100644 index 000000000..dd0d35492 --- /dev/null +++ b/saas/lib/fizzy/saas.rb @@ -0,0 +1,7 @@ +require "fizzy/saas/version" +require "fizzy/saas/engine" + +module Fizzy + module Saas + end +end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb new file mode 100644 index 000000000..db24c8557 --- /dev/null +++ b/saas/lib/fizzy/saas/engine.rb @@ -0,0 +1,19 @@ +module Fizzy + module Saas + class Engine < ::Rails::Engine + # moved from config/initializers/queenbee.rb + Queenbee.host_app = Fizzy + + config.to_prepare do + Queenbee::Subscription.short_names = Subscription::SHORT_NAMES + Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) + + Subscription::SHORT_NAMES.each do |short_name| + const_name = "#{short_name}Subscription" + ::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name) + ::Object.const_set const_name, Subscription.const_get(short_name, false) + end + end + end + end +end diff --git a/saas/lib/fizzy/saas/version.rb b/saas/lib/fizzy/saas/version.rb new file mode 100644 index 000000000..7a95d2d05 --- /dev/null +++ b/saas/lib/fizzy/saas/version.rb @@ -0,0 +1,5 @@ +module Fizzy + module Saas + VERSION = "0.1.0" + end +end diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake new file mode 100644 index 000000000..8fe948d94 --- /dev/null +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -0,0 +1,4 @@ +# desc "Explaining what the task does" +# task :fizzy_saas do +# # Task goes here +# end diff --git a/saas/test/controllers/.keep b/saas/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/controllers/signups/completions_controller_test.rb b/saas/test/controllers/signups/completions_controller_test.rb new file mode 100644 index 000000000..49628a188 --- /dev/null +++ b/saas/test/controllers/signups/completions_controller_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest + setup do + @signup = Signup.new(email_address: "newuser@example.com", full_name: "New User") + + @signup.create_identity || raise("Failed to create identity") + + sign_in_as @signup.identity + end + + test "new" do + untenanted do + get saas.new_signup_completion_path + end + + assert_response :success + end + + test "create" do + untenanted do + post saas.signup_completion_path, params: { + signup: { + full_name: @signup.full_name + } + } + end + + assert_response :redirect, "Valid params should redirect" + end + + test "create with invalid params" do + untenanted do + post saas.signup_completion_path, params: { + signup: { + full_name: "" + } + } + end + + assert_response :unprocessable_entity, "Invalid params should return unprocessable entity" + end +end diff --git a/saas/test/fixtures/files/.keep b/saas/test/fixtures/files/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/helpers/.keep b/saas/test/helpers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/integration/.keep b/saas/test/integration/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/mailers/.keep b/saas/test/mailers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/models/.keep b/saas/test/models/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/models/signup/account_name_generator_test.rb b/saas/test/models/signup/account_name_generator_test.rb new file mode 100644 index 000000000..d22922b29 --- /dev/null +++ b/saas/test/models/signup/account_name_generator_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase + setup do + @identity = Identity.create!(email_address: "newart.userbaum@example.com") + @name = "Newart userbaum" + @generator = Signup::AccountNameGenerator.new(identity: @identity, name: @name) + end + + test "generate" do + account_name = @generator.generate + assert_equal "Newart's Fizzy", account_name, "The 1st account doesn't have 1st in the name" + + first_account = Account.create!(external_account_id: "1st", name: account_name) + Current.without_account do + @identity.users.create!(account: first_account, name: @name) + @identity.reload + end + + account_name = @generator.generate + assert_equal "Newart's 2nd Fizzy", account_name + + second_account = Account.create!(external_account_id: "2nd", name: account_name) + Current.without_account do + @identity.users.create!(account: second_account, name: @name) + @identity.reload + end + + account_name = @generator.generate + assert_equal "Newart's 3rd Fizzy", account_name + + third_account = Account.create!(external_account_id: "3rd", name: account_name) + Current.without_account do + @identity.users.create!(account: third_account, name: @name) + @identity.reload + end + + account_name = @generator.generate + assert_equal "Newart's 4th Fizzy", account_name + + fourth_account = Account.create!(external_account_id: "4th", name: account_name) + Current.without_account do + @identity.users.create!(account: fourth_account, name: @name) + @identity.reload + end + + account_name = @generator.generate + assert_equal "Newart's 5th Fizzy", account_name + end + + test "generate continues from the previous highest index" do + account = Account.create!(external_account_id: "12th", name: "Newart's 12th Fizzy") + Current.without_account do + @identity.users.create!(account: account, name: @name) + @identity.reload + end + + account_name = @generator.generate + assert_equal "Newart's 13th Fizzy", account_name + end +end diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb new file mode 100644 index 000000000..69c0e699f --- /dev/null +++ b/saas/test/models/signup_test.rb @@ -0,0 +1,53 @@ +require "test_helper" + +class SignupTest < ActiveSupport::TestCase + test "#create_identity" do + signup = Signup.new(email_address: "brian@example.com") + + assert_difference -> { Identity.count }, 1 do + assert_difference -> { MagicLink.count }, 1 do + assert signup.create_identity + end + end + + assert_empty signup.errors + assert signup.identity + assert signup.identity.persisted? + + signup_existing = Signup.new(email_address: "brian@example.com") + + assert_no_difference -> { Identity.count } do + assert_difference -> { MagicLink.count }, 1 do + assert signup_existing.create_identity, "Should send magic link for existing identity" + end + end + + signup_invalid = Signup.new(email_address: "") + assert_raises do + signup_invalid.create_identity + end + end + + test "#complete" do + Account.any_instance.expects(:setup_customer_template).once + Current.without_account do + signup = Signup.new( + full_name: "Kevin", + identity: identities(:kevin) + ) + + assert signup.complete + + assert signup.account + assert signup.user + assert_equal "Kevin", signup.user.name + + signup_invalid = Signup.new( + full_name: "", + identity: identities(:kevin) + ) + assert_not signup_invalid.complete + assert_not_empty signup_invalid.errors[:full_name] + end + end +end diff --git a/saas/test/test_helper.rb b/saas/test/test_helper.rb new file mode 100644 index 000000000..eeaf24954 --- /dev/null +++ b/saas/test/test_helper.rb @@ -0,0 +1,9 @@ +require "queenbee/testing/mocks" + +Queenbee::Remote::Account.class_eval do + # because we use the account ID as the tenant name, we need it to be unique in each test to avoid + # parallelized tests clobbering each other. + def next_id + super + Random.rand(1000000) + end +end From d25aca66677d9f562c2a678de946f11d4bdd29eb Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:11:30 +0100 Subject: [PATCH 002/318] Add ruby version --- saas/.ruby-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 saas/.ruby-version diff --git a/saas/.ruby-version b/saas/.ruby-version new file mode 100644 index 000000000..2aa513199 --- /dev/null +++ b/saas/.ruby-version @@ -0,0 +1 @@ +3.4.7 From 461fdd18150304e019063593aa85e7feccabe51d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:32:06 +0100 Subject: [PATCH 003/318] Remove GH actions, we will run the tests with the host app --- saas/.github/dependabot.yml | 12 ------ saas/.github/workflows/ci.yml | 69 ----------------------------------- 2 files changed, 81 deletions(-) delete mode 100644 saas/.github/dependabot.yml delete mode 100644 saas/.github/workflows/ci.yml diff --git a/saas/.github/dependabot.yml b/saas/.github/dependabot.yml deleted file mode 100644 index 83610cfa4..000000000 --- a/saas/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -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/saas/.github/workflows/ci.yml b/saas/.github/workflows/ci.yml deleted file mode 100644 index ef5e97c73..000000000 --- a/saas/.github/workflows/ci.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [ main ] - -jobs: - lint: - runs-on: ubuntu-latest - env: - RUBY_VERSION: ruby-3.4.5 - RUBOCOP_CACHE_ROOT: tmp/rubocop - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ env.RUBY_VERSION }} - bundler-cache: true - - - name: Prepare RuboCop cache - uses: actions/cache@v4 - env: - DEPENDENCIES_HASH: ${{ hashFiles('**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }} - with: - path: ${{ env.RUBOCOP_CACHE_ROOT }} - key: rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} - restore-keys: | - rubocop-${{ runner.os }}-${{ env.RUBY_VERSION }}-${{ env.DEPENDENCIES_HASH }}- - - - name: Lint code for consistent style - run: bin/rubocop -f github - - test: - runs-on: ubuntu-latest - - # services: - # redis: - # image: valkey/valkey:8 - # ports: - # - 6379:6379 - # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ruby-3.4.5 - bundler-cache: true - - - name: Run tests - env: - RAILS_ENV: test - # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test - - - name: Keep screenshots from failed system tests - uses: actions/upload-artifact@v4 - if: failure() - with: - name: screenshots - path: ${{ github.workspace }}/tmp/screenshots - if-no-files-found: ignore From bfe671e707e6fb6b8b966c9e72e3f85fe08507f2 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:32:52 +0100 Subject: [PATCH 004/318] Add private dependency here --- saas/Gemfile | 1 + saas/Gemfile.lock | 181 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 170 insertions(+), 12 deletions(-) diff --git a/saas/Gemfile b/saas/Gemfile index 396a3192e..531e8c25c 100644 --- a/saas/Gemfile +++ b/saas/Gemfile @@ -3,4 +3,5 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } # 37id and Queenbee integration gem "queenbee", bc: "queenbee-plugin", ref: "eb01c697de1ad028afc65cc7d9b5345a7a8e849f" +gem "rails_structured_logging", bc: "rails-structured-logging" gem "activeresource", require: "active_resource" # needed by queenbee diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock index 5d83987cd..a7c5d9ab5 100644 --- a/saas/Gemfile.lock +++ b/saas/Gemfile.lock @@ -8,62 +8,219 @@ GIT builder rexml +GIT + remote: https://github.com/basecamp/rails-structured-logging + revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 + specs: + rails_structured_logging (0.2.1) + json + rails (>= 6.0.0) + GEM remote: https://rubygems.org/ specs: - activemodel (8.0.2.1) - activesupport (= 8.0.2.1) + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.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.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.1) + activesupport (= 8.1.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.1) + activesupport (= 8.1.1) + globalid (>= 0.3.6) + activemodel (8.1.1) + activesupport (= 8.1.1) activemodel-serializers-xml (1.0.3) activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) builder (~> 3.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) + timeout (>= 0.4.0) activeresource (6.1.4) activemodel (>= 6.0) activemodel-serializers-xml (~> 1.0) activesupport (>= 6.0) - activesupport (8.0.2.1) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) + marcel (~> 1.0) + activesupport (8.1.1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) base64 (0.3.0) - benchmark (0.4.1) bigdecimal (3.2.3) builder (3.3.0) concurrent-ruby (1.3.5) connection_pool (2.5.4) + crass (1.0.6) + date (3.5.0) drb (2.2.3) + erb (6.0.0) + erubi (1.13.1) + globalid (1.3.0) + activesupport (>= 6.1) i18n (1.14.7) concurrent-ruby (~> 1.0) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.16.0) logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + mini_mime (1.1.5) minitest (5.25.5) + net-imap (0.5.12) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + psych (5.2.6) + date + stringio + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + bundler (>= 1.15.0) + railties (= 8.1.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + 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.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rake (13.3.1) + rdoc (6.15.1) + erb + psych (>= 4.0.0) + tsort + reline (0.6.3) + io-console (~> 0.5) rexml (3.4.4) securerandom (0.4.1) + stringio (3.1.8) + thor (1.4.0) + timeout (0.4.4) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uri (1.0.3) + useragent (0.16.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.3) PLATFORMS - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl arm64-darwin - x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl DEPENDENCIES activeresource queenbee! + rails_structured_logging! BUNDLED WITH 2.7.0 From 0a4590a841b4f688d75c35592d9c74bd28ff70f9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:48:00 +0100 Subject: [PATCH 005/318] Fix problems referencing host model classes --- .../signup/account_name_generator_test.rb | 22 +++++++++---------- saas/test/models/signup_test.rb | 12 +++++----- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/saas/test/models/signup/account_name_generator_test.rb b/saas/test/models/signup/account_name_generator_test.rb index d22922b29..e4d8e0d80 100644 --- a/saas/test/models/signup/account_name_generator_test.rb +++ b/saas/test/models/signup/account_name_generator_test.rb @@ -2,7 +2,7 @@ require "test_helper" class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase setup do - @identity = Identity.create!(email_address: "newart.userbaum@example.com") + @identity = ::Identity.create!(email_address: "newart.userbaum@example.com") @name = "Newart userbaum" @generator = Signup::AccountNameGenerator.new(identity: @identity, name: @name) end @@ -11,8 +11,8 @@ class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase account_name = @generator.generate assert_equal "Newart's Fizzy", account_name, "The 1st account doesn't have 1st in the name" - first_account = Account.create!(external_account_id: "1st", name: account_name) - Current.without_account do + first_account = ::Account.create!(external_account_id: "1st", name: account_name) + ::Current.without_account do @identity.users.create!(account: first_account, name: @name) @identity.reload end @@ -20,8 +20,8 @@ class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase account_name = @generator.generate assert_equal "Newart's 2nd Fizzy", account_name - second_account = Account.create!(external_account_id: "2nd", name: account_name) - Current.without_account do + second_account = ::Account.create!(external_account_id: "2nd", name: account_name) + ::Current.without_account do @identity.users.create!(account: second_account, name: @name) @identity.reload end @@ -29,8 +29,8 @@ class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase account_name = @generator.generate assert_equal "Newart's 3rd Fizzy", account_name - third_account = Account.create!(external_account_id: "3rd", name: account_name) - Current.without_account do + third_account = ::Account.create!(external_account_id: "3rd", name: account_name) + ::Current.without_account do @identity.users.create!(account: third_account, name: @name) @identity.reload end @@ -38,8 +38,8 @@ class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase account_name = @generator.generate assert_equal "Newart's 4th Fizzy", account_name - fourth_account = Account.create!(external_account_id: "4th", name: account_name) - Current.without_account do + fourth_account = ::Account.create!(external_account_id: "4th", name: account_name) + ::Current.without_account do @identity.users.create!(account: fourth_account, name: @name) @identity.reload end @@ -49,8 +49,8 @@ class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase end test "generate continues from the previous highest index" do - account = Account.create!(external_account_id: "12th", name: "Newart's 12th Fizzy") - Current.without_account do + account = ::Account.create!(external_account_id: "12th", name: "Newart's 12th Fizzy") + ::Current.without_account do @identity.users.create!(account: account, name: @name) @identity.reload end diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb index 69c0e699f..335a1f0eb 100644 --- a/saas/test/models/signup_test.rb +++ b/saas/test/models/signup_test.rb @@ -4,8 +4,8 @@ class SignupTest < ActiveSupport::TestCase test "#create_identity" do signup = Signup.new(email_address: "brian@example.com") - assert_difference -> { Identity.count }, 1 do - assert_difference -> { MagicLink.count }, 1 do + assert_difference -> { ::Identity.count }, 1 do + assert_difference -> { ::MagicLink.count }, 1 do assert signup.create_identity end end @@ -16,8 +16,8 @@ class SignupTest < ActiveSupport::TestCase signup_existing = Signup.new(email_address: "brian@example.com") - assert_no_difference -> { Identity.count } do - assert_difference -> { MagicLink.count }, 1 do + assert_no_difference -> { ::Identity.count } do + assert_difference -> { ::MagicLink.count }, 1 do assert signup_existing.create_identity, "Should send magic link for existing identity" end end @@ -29,8 +29,8 @@ class SignupTest < ActiveSupport::TestCase end test "#complete" do - Account.any_instance.expects(:setup_customer_template).once - Current.without_account do + ::Account.any_instance.expects(:setup_customer_template).once + ::Current.without_account do signup = Signup.new( full_name: "Kevin", identity: identities(:kevin) From ff6b3d4428fe9572aa0c162b51b8a6955089ed9e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:48:12 +0100 Subject: [PATCH 006/318] Add rake task to run tests from host app --- saas/lib/tasks/fizzy/saas_tasks.rake | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index 8fe948d94..3102030bb 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -1,4 +1,16 @@ -# desc "Explaining what the task does" -# task :fizzy_saas do -# # Task goes here -# end +require "rake/testtask" + +namespace :test do + desc "Run tests for fizzy-saas gem" + Rake::TestTask.new(:saas) do |t| + t.libs << "test" + t.test_files = FileList[Fizzy::Saas::Engine.root.join("test/**/*_test.rb")] + t.verbose = true + end +end + +task "test:saas" => :environment do + $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s + + require "test_helper" +end From 3eaa2ed774c153fd12e7891239860650e37ec236 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:51:51 +0100 Subject: [PATCH 007/318] Suppress warnings --- saas/lib/tasks/fizzy/saas_tasks.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index 3102030bb..499c1b755 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -5,7 +5,7 @@ namespace :test do Rake::TestTask.new(:saas) do |t| t.libs << "test" t.test_files = FileList[Fizzy::Saas::Engine.root.join("test/**/*_test.rb")] - t.verbose = true + t.warning = false end end From 406658967fe668573c1683c5c2129180ed4c11ca Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:58:17 +0100 Subject: [PATCH 008/318] The task extension was not working --- saas/lib/tasks/fizzy/saas_tasks.rake | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index 499c1b755..74923fd28 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -1,16 +1,15 @@ require "rake/testtask" namespace :test do + task :prepare_saas => :environment do + $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s + require "test_helper" + end + desc "Run tests for fizzy-saas gem" - Rake::TestTask.new(:saas) do |t| + Rake::TestTask.new(:saas => :prepare_saas) do |t| t.libs << "test" t.test_files = FileList[Fizzy::Saas::Engine.root.join("test/**/*_test.rb")] t.warning = false end end - -task "test:saas" => :environment do - $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s - - require "test_helper" -end From d3f382c02335e7dd00d1f692d1721272a913f627 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 07:37:19 +0100 Subject: [PATCH 009/318] Simplify (still 2 failures) --- saas/lib/tasks/fizzy/saas_tasks.rake | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index 74923fd28..b0826657d 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -1,13 +1,15 @@ require "rake/testtask" namespace :test do - task :prepare_saas => :environment do - $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s - require "test_helper" - end + # task :prepare_saas => :environment do + # require "rails/test_help" + # + # $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s + # require Fizzy::Saas::Engine.root.join("test/test_helper") + # end desc "Run tests for fizzy-saas gem" - Rake::TestTask.new(:saas => :prepare_saas) do |t| + Rake::TestTask.new(:saas => :environment) do |t| t.libs << "test" t.test_files = FileList[Fizzy::Saas::Engine.root.join("test/**/*_test.rb")] t.warning = false From 7d47ec7534a0a9b86b52893adfbe22d9d345b6f6 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 09:32:12 +0100 Subject: [PATCH 010/318] Make engine automount for now so that we can remove that bit from the main app --- saas/lib/fizzy/saas/engine.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index db24c8557..af56f1ed9 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -4,6 +4,12 @@ module Fizzy # moved from config/initializers/queenbee.rb Queenbee.host_app = Fizzy + initializer "fizzy.saas.mount" do |app| + app.routes.append do + mount Fizzy::Saas::Engine => "/", as: "saas" + end + end + config.to_prepare do Queenbee::Subscription.short_names = Subscription::SHORT_NAMES Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) From 2786ab34ace8f2ad4a5f10e17a8f1e8b6e6f7ad3 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 18:22:02 +0100 Subject: [PATCH 011/318] Bring setup specific code to the gemm --- saas/bin/setup | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100755 saas/bin/setup diff --git a/saas/bin/setup b/saas/bin/setup new file mode 100755 index 000000000..bdebb3a1a --- /dev/null +++ b/saas/bin/setup @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -eo pipefail + +# SaaS-specific setup tasks +# This script is called from the main Fizzy bin/setup when SAAS is enabled + +if [ -e tmp/minio-dev.txt ]; then + step "Starting Docker services" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup minio" + step "Configuring MinIO" bin/minio-setup +fi + +step "Starting mysql" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup mysql80" From 5ede52991f3958c4b2e0846c4fe5aeb485a9ad9f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 18:48:10 +0100 Subject: [PATCH 012/318] Bring Dockerfile from Fizzy and configure the deploy setup to use it --- saas/Dockerfile | 77 ++++++++++++++++++++++++++++++++++++++++++ saas/config/deploy.yml | 1 + 2 files changed, 78 insertions(+) create mode 100644 saas/Dockerfile diff --git a/saas/Dockerfile b/saas/Dockerfile new file mode 100644 index 000000000..ef03c0ca1 --- /dev/null +++ b/saas/Dockerfile @@ -0,0 +1,77 @@ +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.4.7 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends -y build-essential pkg-config git libvips libyaml-dev libssl-dev && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock .ruby-version ./ +COPY lib/fizzy.rb ./lib/fizzy.rb +COPY gems ./gems/ +RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-permabundle-${RUBY_VERSION},sharing=locked,target=/permabundle \ + gem install bundler && \ + BUNDLE_PATH=/permabundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \ + cp -a /permabundle/. "$BUNDLE_PATH"/ && \ + bundle clean --force && \ + rm -rf "$BUNDLE_PATH"/ruby/*/bundler/gems/*/.git && \ + find "$BUNDLE_PATH" -type f \( -name '*.gem' -o -iname '*.a' -o -iname '*.o' -o -iname '*.h' -o -iname '*.c' -o -iname '*.hpp' -o -iname '*.cpp' \) -delete && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + +# Final stage for app image +FROM base + +# Install packages needed for deployment +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools sqlite3 libjemalloc-dev && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Copy built artifacts: gems, application +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN useradd rails --create-home --shell /bin/bash && \ + chown -R rails:rails db log storage tmp +USER rails:rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Ruby GC tuning values pulled from Autotuner recommendations +ENV RUBY_GC_HEAP_0_INIT_SLOTS=692636 \ + RUBY_GC_HEAP_1_INIT_SLOTS=175943 \ + RUBY_GC_HEAP_2_INIT_SLOTS=148807 \ + RUBY_GC_HEAP_3_INIT_SLOTS=9169 \ + RUBY_GC_HEAP_4_INIT_SLOTS=3054 \ + RUBY_GC_MALLOC_LIMIT=33554432 \ + RUBY_GC_MALLOC_LIMIT_MAX=67108864 \ + LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 + +# Start the server by default, this can be overwritten at runtime +EXPOSE 80 443 9394 +CMD ["./bin/thrust", "./bin/rails", "server"] \ No newline at end of file diff --git a/saas/config/deploy.yml b/saas/config/deploy.yml index ea3ca3c49..39d8d22b6 100644 --- a/saas/config/deploy.yml +++ b/saas/config/deploy.yml @@ -21,6 +21,7 @@ registry: builder: arch: amd64 + dockerfile: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "Dockerfile") %> secrets: - GITHUB_TOKEN remote: ssh://app@docker-builder-102 From d038518b05c204edfbf63a9b950d2cd2d22c170d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 18:50:49 +0100 Subject: [PATCH 013/318] Prepare Dockerfile to support the new multi-gemfile setup for saas --- saas/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/saas/Dockerfile b/saas/Dockerfile index ef03c0ca1..3910687aa 100644 --- a/saas/Dockerfile +++ b/saas/Dockerfile @@ -8,7 +8,9 @@ WORKDIR /rails # Set production environment ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ + SAAS="1" \ BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_GEMFILE="Gemfile.saas" \ BUNDLE_WITHOUT="development" @@ -21,7 +23,7 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems -COPY Gemfile Gemfile.lock .ruby-version ./ +COPY Gemfile Gemfile.lock Gemfile.saas Gemfile.saas.lock .ruby-version ./ COPY lib/fizzy.rb ./lib/fizzy.rb COPY gems ./gems/ RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-permabundle-${RUBY_VERSION},sharing=locked,target=/permabundle \ @@ -74,4 +76,4 @@ ENV RUBY_GC_HEAP_0_INIT_SLOTS=692636 \ # Start the server by default, this can be overwritten at runtime EXPOSE 80 443 9394 -CMD ["./bin/thrust", "./bin/rails", "server"] \ No newline at end of file +CMD ["./bin/thrust", "./bin/rails", "server"] From b58759e962678fa8ebd70c5927f6023ad818c690 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 19:10:12 +0100 Subject: [PATCH 014/318] We removed the gems directory --- saas/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/saas/Dockerfile b/saas/Dockerfile index 3910687aa..2b08addf5 100644 --- a/saas/Dockerfile +++ b/saas/Dockerfile @@ -25,7 +25,6 @@ RUN apt-get update -qq && \ # Install application gems COPY Gemfile Gemfile.lock Gemfile.saas Gemfile.saas.lock .ruby-version ./ COPY lib/fizzy.rb ./lib/fizzy.rb -COPY gems ./gems/ RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-permabundle-${RUBY_VERSION},sharing=locked,target=/permabundle \ gem install bundler && \ BUNDLE_PATH=/permabundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \ From 63c9bd825645a01e39a4a7dc2d5506236e94a015 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 20:22:42 +0100 Subject: [PATCH 015/318] Add tasks for enable/disabling sass mode via rake task --- saas/lib/tasks/fizzy/saas_tasks.rake | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index b0826657d..06625d353 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -15,3 +15,22 @@ namespace :test do t.warning = false end end + +namespace :saas do + SAAS_FILE_PATH = "tmp/saas.txt" + + desc "Enable SaaS mode" + task :enable => :environment do + file_path = Rails.root.join(SAAS_FILE_PATH) + FileUtils.mkdir_p(File.dirname(file_path)) + FileUtils.touch(file_path) + puts "SaaS mode enabled (#{file_path} created)" + end + + desc "Disable SaaS mode" + task :disable => :environment do + file_path = Rails.root.join(SAAS_FILE_PATH) + FileUtils.rm_f(file_path) + puts "SaaS mode disabled (#{file_path} removed)" + end +end From 79e1861dbb0cbbeab8dee3fe9c875adb0b4c7a39 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 20:24:32 +0100 Subject: [PATCH 016/318] This should be added to the root fizzy project so that you dont have to set the instance var! --- saas/lib/tasks/fizzy/saas_tasks.rake | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index 06625d353..ea60d0454 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -16,21 +16,3 @@ namespace :test do end end -namespace :saas do - SAAS_FILE_PATH = "tmp/saas.txt" - - desc "Enable SaaS mode" - task :enable => :environment do - file_path = Rails.root.join(SAAS_FILE_PATH) - FileUtils.mkdir_p(File.dirname(file_path)) - FileUtils.touch(file_path) - puts "SaaS mode enabled (#{file_path} created)" - end - - desc "Disable SaaS mode" - task :disable => :environment do - file_path = Rails.root.join(SAAS_FILE_PATH) - FileUtils.rm_f(file_path) - puts "SaaS mode disabled (#{file_path} removed)" - end -end From 041ac0c28e1a3ac4c7df2a6d64f152bf23cbb963 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 24 Nov 2025 11:52:56 +0100 Subject: [PATCH 017/318] Add conditional stylesheets for mobile apps --- app/assets/stylesheets/android/_global.css | 3 ++ app/assets/stylesheets/ios/_global.css | 3 ++ app/assets/stylesheets/mobile_app/_global.css | 6 +++ app/assets/stylesheets/{ => web}/_global.css | 2 +- .../stylesheets/{ => web}/animation.css | 0 .../stylesheets/{ => web}/autoresize.css | 0 app/assets/stylesheets/{ => web}/avatars.css | 0 app/assets/stylesheets/{ => web}/bar.css | 0 app/assets/stylesheets/{ => web}/base.css | 0 app/assets/stylesheets/{ => web}/bubble.css | 0 app/assets/stylesheets/{ => web}/buttons.css | 0 .../stylesheets/{ => web}/card-columns.css | 0 .../stylesheets/{ => web}/card-perma.css | 0 app/assets/stylesheets/{ => web}/cards.css | 0 .../stylesheets/{ => web}/circled-text.css | 0 .../stylesheets/{ => web}/color-picker.css | 0 app/assets/stylesheets/{ => web}/comments.css | 0 app/assets/stylesheets/{ => web}/dialog.css | 0 app/assets/stylesheets/{ => web}/dividers.css | 0 .../stylesheets/{ => web}/drag_and_drop.css | 0 app/assets/stylesheets/{ => web}/events.css | 1 + app/assets/stylesheets/{ => web}/filters.css | 0 app/assets/stylesheets/{ => web}/flash.css | 0 .../stylesheets/{ => web}/golden-effect.css | 0 app/assets/stylesheets/{ => web}/header.css | 11 +++-- app/assets/stylesheets/{ => web}/icons.css | 0 app/assets/stylesheets/{ => web}/inputs.css | 0 app/assets/stylesheets/{ => web}/knobs.css | 0 app/assets/stylesheets/{ => web}/layout.css | 0 app/assets/stylesheets/{ => web}/lexxy.css | 0 app/assets/stylesheets/{ => web}/lightbox.css | 0 app/assets/stylesheets/{ => web}/markdown.css | 0 app/assets/stylesheets/{ => web}/nav.css | 0 .../stylesheets/{ => web}/notifications.css | 0 .../stylesheets/{ => web}/pagination.css | 0 app/assets/stylesheets/{ => web}/panels.css | 0 app/assets/stylesheets/{ => web}/popup.css | 0 app/assets/stylesheets/{ => web}/print.css | 0 .../stylesheets/{ => web}/profile-layout.css | 0 app/assets/stylesheets/{ => web}/pwa.css | 0 app/assets/stylesheets/{ => web}/qr-codes.css | 0 .../stylesheets/{ => web}/reactions.css | 0 app/assets/stylesheets/{ => web}/reset.css | 0 .../{ => web}/rich-text-content.css | 0 app/assets/stylesheets/{ => web}/search.css | 0 .../stylesheets/{ => web}/separators.css | 0 app/assets/stylesheets/{ => web}/settings.css | 0 app/assets/stylesheets/{ => web}/spinners.css | 0 app/assets/stylesheets/{ => web}/steps.css | 0 app/assets/stylesheets/{ => web}/syntax.css | 0 app/assets/stylesheets/{ => web}/tags.css | 0 app/assets/stylesheets/{ => web}/toggles.css | 0 app/assets/stylesheets/{ => web}/tooltips.css | 0 app/assets/stylesheets/{ => web}/trays.css | 0 app/assets/stylesheets/{ => web}/user.css | 0 .../stylesheets/{ => web}/utilities.css | 0 app/helpers/application_helper.rb | 4 ++ app/models/application_platform.rb | 40 +++++++++++++++++++ app/views/layouts/shared/_head.html.erb | 2 +- 59 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 app/assets/stylesheets/android/_global.css create mode 100644 app/assets/stylesheets/ios/_global.css create mode 100644 app/assets/stylesheets/mobile_app/_global.css rename app/assets/stylesheets/{ => web}/_global.css (99%) rename app/assets/stylesheets/{ => web}/animation.css (100%) rename app/assets/stylesheets/{ => web}/autoresize.css (100%) rename app/assets/stylesheets/{ => web}/avatars.css (100%) rename app/assets/stylesheets/{ => web}/bar.css (100%) rename app/assets/stylesheets/{ => web}/base.css (100%) rename app/assets/stylesheets/{ => web}/bubble.css (100%) rename app/assets/stylesheets/{ => web}/buttons.css (100%) rename app/assets/stylesheets/{ => web}/card-columns.css (100%) rename app/assets/stylesheets/{ => web}/card-perma.css (100%) rename app/assets/stylesheets/{ => web}/cards.css (100%) rename app/assets/stylesheets/{ => web}/circled-text.css (100%) rename app/assets/stylesheets/{ => web}/color-picker.css (100%) rename app/assets/stylesheets/{ => web}/comments.css (100%) rename app/assets/stylesheets/{ => web}/dialog.css (100%) rename app/assets/stylesheets/{ => web}/dividers.css (100%) rename app/assets/stylesheets/{ => web}/drag_and_drop.css (100%) rename app/assets/stylesheets/{ => web}/events.css (99%) rename app/assets/stylesheets/{ => web}/filters.css (100%) rename app/assets/stylesheets/{ => web}/flash.css (100%) rename app/assets/stylesheets/{ => web}/golden-effect.css (100%) rename app/assets/stylesheets/{ => web}/header.css (95%) rename app/assets/stylesheets/{ => web}/icons.css (100%) rename app/assets/stylesheets/{ => web}/inputs.css (100%) rename app/assets/stylesheets/{ => web}/knobs.css (100%) rename app/assets/stylesheets/{ => web}/layout.css (100%) rename app/assets/stylesheets/{ => web}/lexxy.css (100%) rename app/assets/stylesheets/{ => web}/lightbox.css (100%) rename app/assets/stylesheets/{ => web}/markdown.css (100%) rename app/assets/stylesheets/{ => web}/nav.css (100%) rename app/assets/stylesheets/{ => web}/notifications.css (100%) rename app/assets/stylesheets/{ => web}/pagination.css (100%) rename app/assets/stylesheets/{ => web}/panels.css (100%) rename app/assets/stylesheets/{ => web}/popup.css (100%) rename app/assets/stylesheets/{ => web}/print.css (100%) rename app/assets/stylesheets/{ => web}/profile-layout.css (100%) rename app/assets/stylesheets/{ => web}/pwa.css (100%) rename app/assets/stylesheets/{ => web}/qr-codes.css (100%) rename app/assets/stylesheets/{ => web}/reactions.css (100%) rename app/assets/stylesheets/{ => web}/reset.css (100%) rename app/assets/stylesheets/{ => web}/rich-text-content.css (100%) rename app/assets/stylesheets/{ => web}/search.css (100%) rename app/assets/stylesheets/{ => web}/separators.css (100%) rename app/assets/stylesheets/{ => web}/settings.css (100%) rename app/assets/stylesheets/{ => web}/spinners.css (100%) rename app/assets/stylesheets/{ => web}/steps.css (100%) rename app/assets/stylesheets/{ => web}/syntax.css (100%) rename app/assets/stylesheets/{ => web}/tags.css (100%) rename app/assets/stylesheets/{ => web}/toggles.css (100%) rename app/assets/stylesheets/{ => web}/tooltips.css (100%) rename app/assets/stylesheets/{ => web}/trays.css (100%) rename app/assets/stylesheets/{ => web}/user.css (100%) rename app/assets/stylesheets/{ => web}/utilities.css (100%) diff --git a/app/assets/stylesheets/android/_global.css b/app/assets/stylesheets/android/_global.css new file mode 100644 index 000000000..247ac7ba0 --- /dev/null +++ b/app/assets/stylesheets/android/_global.css @@ -0,0 +1,3 @@ +@layer android { + /* Android app only styles go here */ +} diff --git a/app/assets/stylesheets/ios/_global.css b/app/assets/stylesheets/ios/_global.css new file mode 100644 index 000000000..be4178b36 --- /dev/null +++ b/app/assets/stylesheets/ios/_global.css @@ -0,0 +1,3 @@ +@layer ios { + /* iOS app only styles go here */ +} diff --git a/app/assets/stylesheets/mobile_app/_global.css b/app/assets/stylesheets/mobile_app/_global.css new file mode 100644 index 000000000..eae613d38 --- /dev/null +++ b/app/assets/stylesheets/mobile_app/_global.css @@ -0,0 +1,6 @@ +@layer mobile_app { + /* Mobile app only styles go here */ + * { + color: red !important; + } +} diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/web/_global.css similarity index 99% rename from app/assets/stylesheets/_global.css rename to app/assets/stylesheets/web/_global.css index 9c0b30f0f..4f017523c 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/web/_global.css @@ -1,4 +1,4 @@ -@layer reset, base, components, modules, utilities; +@layer reset, base, components, modules, utilities, mobile_app, android, ios; :root { /* Spacing */ diff --git a/app/assets/stylesheets/animation.css b/app/assets/stylesheets/web/animation.css similarity index 100% rename from app/assets/stylesheets/animation.css rename to app/assets/stylesheets/web/animation.css diff --git a/app/assets/stylesheets/autoresize.css b/app/assets/stylesheets/web/autoresize.css similarity index 100% rename from app/assets/stylesheets/autoresize.css rename to app/assets/stylesheets/web/autoresize.css diff --git a/app/assets/stylesheets/avatars.css b/app/assets/stylesheets/web/avatars.css similarity index 100% rename from app/assets/stylesheets/avatars.css rename to app/assets/stylesheets/web/avatars.css diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/web/bar.css similarity index 100% rename from app/assets/stylesheets/bar.css rename to app/assets/stylesheets/web/bar.css diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/web/base.css similarity index 100% rename from app/assets/stylesheets/base.css rename to app/assets/stylesheets/web/base.css diff --git a/app/assets/stylesheets/bubble.css b/app/assets/stylesheets/web/bubble.css similarity index 100% rename from app/assets/stylesheets/bubble.css rename to app/assets/stylesheets/web/bubble.css diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/web/buttons.css similarity index 100% rename from app/assets/stylesheets/buttons.css rename to app/assets/stylesheets/web/buttons.css diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/web/card-columns.css similarity index 100% rename from app/assets/stylesheets/card-columns.css rename to app/assets/stylesheets/web/card-columns.css diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/web/card-perma.css similarity index 100% rename from app/assets/stylesheets/card-perma.css rename to app/assets/stylesheets/web/card-perma.css diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/web/cards.css similarity index 100% rename from app/assets/stylesheets/cards.css rename to app/assets/stylesheets/web/cards.css diff --git a/app/assets/stylesheets/circled-text.css b/app/assets/stylesheets/web/circled-text.css similarity index 100% rename from app/assets/stylesheets/circled-text.css rename to app/assets/stylesheets/web/circled-text.css diff --git a/app/assets/stylesheets/color-picker.css b/app/assets/stylesheets/web/color-picker.css similarity index 100% rename from app/assets/stylesheets/color-picker.css rename to app/assets/stylesheets/web/color-picker.css diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/web/comments.css similarity index 100% rename from app/assets/stylesheets/comments.css rename to app/assets/stylesheets/web/comments.css diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/web/dialog.css similarity index 100% rename from app/assets/stylesheets/dialog.css rename to app/assets/stylesheets/web/dialog.css diff --git a/app/assets/stylesheets/dividers.css b/app/assets/stylesheets/web/dividers.css similarity index 100% rename from app/assets/stylesheets/dividers.css rename to app/assets/stylesheets/web/dividers.css diff --git a/app/assets/stylesheets/drag_and_drop.css b/app/assets/stylesheets/web/drag_and_drop.css similarity index 100% rename from app/assets/stylesheets/drag_and_drop.css rename to app/assets/stylesheets/web/drag_and_drop.css diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/web/events.css similarity index 99% rename from app/assets/stylesheets/events.css rename to app/assets/stylesheets/web/events.css index 1dbdf0997..c6bd0ff2a 100644 --- a/app/assets/stylesheets/events.css +++ b/app/assets/stylesheets/web/events.css @@ -221,6 +221,7 @@ background-color: var(--color-canvas); grid-row-start: 1; inset-block-start: calc(var(--block-space) * -1); + margin-inline: calc(var(--main-padding) * -1); margin-block-end: var(--events-gap); padding-block: calc(var(--events-gap) * 3) var(--events-gap); position: sticky; diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/web/filters.css similarity index 100% rename from app/assets/stylesheets/filters.css rename to app/assets/stylesheets/web/filters.css diff --git a/app/assets/stylesheets/flash.css b/app/assets/stylesheets/web/flash.css similarity index 100% rename from app/assets/stylesheets/flash.css rename to app/assets/stylesheets/web/flash.css diff --git a/app/assets/stylesheets/golden-effect.css b/app/assets/stylesheets/web/golden-effect.css similarity index 100% rename from app/assets/stylesheets/golden-effect.css rename to app/assets/stylesheets/web/golden-effect.css diff --git a/app/assets/stylesheets/header.css b/app/assets/stylesheets/web/header.css similarity index 95% rename from app/assets/stylesheets/header.css rename to app/assets/stylesheets/web/header.css index 59d94a0f3..8ef1b4094 100644 --- a/app/assets/stylesheets/header.css +++ b/app/assets/stylesheets/web/header.css @@ -39,6 +39,7 @@ .header__actions { display: flex; + align-items: center; font-size: var(--text-x-small); gap: var(--header-gap); inline-size: var(--header-actions-width); @@ -117,17 +118,15 @@ /* Optional class to stack header actions on small screens /* ------------------------------------------------------------------------ */ - /* .header--mobile-actions-stack { + .header--mobile-actions-stack { @media (max-width: 639px) { - grid-template-columns: 1fr 1fr; grid-template-areas: - "menu menu" - "actions-start actions-end" - "title title"; + "actions-start menu actions-end" + "title title title"; .header__title { margin-block-start: 0.25rem; } } - } */ + } } diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/web/icons.css similarity index 100% rename from app/assets/stylesheets/icons.css rename to app/assets/stylesheets/web/icons.css diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/web/inputs.css similarity index 100% rename from app/assets/stylesheets/inputs.css rename to app/assets/stylesheets/web/inputs.css diff --git a/app/assets/stylesheets/knobs.css b/app/assets/stylesheets/web/knobs.css similarity index 100% rename from app/assets/stylesheets/knobs.css rename to app/assets/stylesheets/web/knobs.css diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/web/layout.css similarity index 100% rename from app/assets/stylesheets/layout.css rename to app/assets/stylesheets/web/layout.css diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/web/lexxy.css similarity index 100% rename from app/assets/stylesheets/lexxy.css rename to app/assets/stylesheets/web/lexxy.css diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/web/lightbox.css similarity index 100% rename from app/assets/stylesheets/lightbox.css rename to app/assets/stylesheets/web/lightbox.css diff --git a/app/assets/stylesheets/markdown.css b/app/assets/stylesheets/web/markdown.css similarity index 100% rename from app/assets/stylesheets/markdown.css rename to app/assets/stylesheets/web/markdown.css diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/web/nav.css similarity index 100% rename from app/assets/stylesheets/nav.css rename to app/assets/stylesheets/web/nav.css diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/web/notifications.css similarity index 100% rename from app/assets/stylesheets/notifications.css rename to app/assets/stylesheets/web/notifications.css diff --git a/app/assets/stylesheets/pagination.css b/app/assets/stylesheets/web/pagination.css similarity index 100% rename from app/assets/stylesheets/pagination.css rename to app/assets/stylesheets/web/pagination.css diff --git a/app/assets/stylesheets/panels.css b/app/assets/stylesheets/web/panels.css similarity index 100% rename from app/assets/stylesheets/panels.css rename to app/assets/stylesheets/web/panels.css diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/web/popup.css similarity index 100% rename from app/assets/stylesheets/popup.css rename to app/assets/stylesheets/web/popup.css diff --git a/app/assets/stylesheets/print.css b/app/assets/stylesheets/web/print.css similarity index 100% rename from app/assets/stylesheets/print.css rename to app/assets/stylesheets/web/print.css diff --git a/app/assets/stylesheets/profile-layout.css b/app/assets/stylesheets/web/profile-layout.css similarity index 100% rename from app/assets/stylesheets/profile-layout.css rename to app/assets/stylesheets/web/profile-layout.css diff --git a/app/assets/stylesheets/pwa.css b/app/assets/stylesheets/web/pwa.css similarity index 100% rename from app/assets/stylesheets/pwa.css rename to app/assets/stylesheets/web/pwa.css diff --git a/app/assets/stylesheets/qr-codes.css b/app/assets/stylesheets/web/qr-codes.css similarity index 100% rename from app/assets/stylesheets/qr-codes.css rename to app/assets/stylesheets/web/qr-codes.css diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/web/reactions.css similarity index 100% rename from app/assets/stylesheets/reactions.css rename to app/assets/stylesheets/web/reactions.css diff --git a/app/assets/stylesheets/reset.css b/app/assets/stylesheets/web/reset.css similarity index 100% rename from app/assets/stylesheets/reset.css rename to app/assets/stylesheets/web/reset.css diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/web/rich-text-content.css similarity index 100% rename from app/assets/stylesheets/rich-text-content.css rename to app/assets/stylesheets/web/rich-text-content.css diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/web/search.css similarity index 100% rename from app/assets/stylesheets/search.css rename to app/assets/stylesheets/web/search.css diff --git a/app/assets/stylesheets/separators.css b/app/assets/stylesheets/web/separators.css similarity index 100% rename from app/assets/stylesheets/separators.css rename to app/assets/stylesheets/web/separators.css diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/web/settings.css similarity index 100% rename from app/assets/stylesheets/settings.css rename to app/assets/stylesheets/web/settings.css diff --git a/app/assets/stylesheets/spinners.css b/app/assets/stylesheets/web/spinners.css similarity index 100% rename from app/assets/stylesheets/spinners.css rename to app/assets/stylesheets/web/spinners.css diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/web/steps.css similarity index 100% rename from app/assets/stylesheets/steps.css rename to app/assets/stylesheets/web/steps.css diff --git a/app/assets/stylesheets/syntax.css b/app/assets/stylesheets/web/syntax.css similarity index 100% rename from app/assets/stylesheets/syntax.css rename to app/assets/stylesheets/web/syntax.css diff --git a/app/assets/stylesheets/tags.css b/app/assets/stylesheets/web/tags.css similarity index 100% rename from app/assets/stylesheets/tags.css rename to app/assets/stylesheets/web/tags.css diff --git a/app/assets/stylesheets/toggles.css b/app/assets/stylesheets/web/toggles.css similarity index 100% rename from app/assets/stylesheets/toggles.css rename to app/assets/stylesheets/web/toggles.css diff --git a/app/assets/stylesheets/tooltips.css b/app/assets/stylesheets/web/tooltips.css similarity index 100% rename from app/assets/stylesheets/tooltips.css rename to app/assets/stylesheets/web/tooltips.css diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/web/trays.css similarity index 100% rename from app/assets/stylesheets/trays.css rename to app/assets/stylesheets/web/trays.css diff --git a/app/assets/stylesheets/user.css b/app/assets/stylesheets/web/user.css similarity index 100% rename from app/assets/stylesheets/user.css rename to app/assets/stylesheets/web/user.css diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/web/utilities.css similarity index 100% rename from app/assets/stylesheets/utilities.css rename to app/assets/stylesheets/web/utilities.css diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cefd6fd37..0897e5b56 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,4 +1,8 @@ module ApplicationHelper + def stylesheet_link_tags + stylesheet_link_tag *platform.stylesheet_paths, "data-turbo-track": "reload" + end + def page_title_tag account_name = if Current.account && Current.session&.identity&.users&.many? Current.account&.name diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index 500f4adc6..59d7c9f78 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -1,4 +1,6 @@ class ApplicationPlatform < PlatformAgent + SCOPED_STYLESHEET_PATHS = {} + def ios? match? /iPhone|iPad/ end @@ -39,6 +41,18 @@ class ApplicationPlatform < PlatformAgent operating_system == "Windows" end + def ios_app? + match? /Fizzy iOS/ + end + + def android_app? + match? /Fizzy Android/ + end + + def mobile_app? + ios_app? || android_app? + end + def operating_system case user_agent.platform when /Android/ then "Android" @@ -51,4 +65,30 @@ class ApplicationPlatform < PlatformAgent os =~ /Linux/ ? "Linux" : os end end + + def stylesheet_asset_name + case + when android_app? then "android" + when ios_app? then "ios" + else "desktop" + end + end + + def stylesheet_paths + scoped_stylesheet_paths("web") + + (mobile_app? ? scoped_stylesheet_paths("mobile_app") : []) + + scoped_stylesheet_paths(stylesheet_asset_name) + end + + def scoped_stylesheet_paths(scope = css_asset_name) + # Allow new stylesheets to be added in dev/test without restarting server + SCOPED_STYLESHEET_PATHS.clear if Rails.env.local? + + SCOPED_STYLESHEET_PATHS[scope] ||= + Rails.root.join("app/assets/stylesheets").then do |stylesheet_root| + stylesheet_root.glob("#{scope}/**/*.css").collect do |path| + path.to_s.remove(stylesheet_root.to_s + "/", ".css") + end + end + end end diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 550b61eaa..d32d21cb9 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -15,7 +15,7 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> - <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= stylesheet_link_tags %> <%= javascript_importmap_tags %> <%= tenanted_action_cable_meta_tag %> From 70fefe5e922497d53921b810fb136ad8bf525b8c Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 24 Nov 2025 11:54:50 +0100 Subject: [PATCH 018/318] Clean unwanted styles --- app/assets/stylesheets/mobile_app/_global.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/assets/stylesheets/mobile_app/_global.css b/app/assets/stylesheets/mobile_app/_global.css index eae613d38..f3ee25327 100644 --- a/app/assets/stylesheets/mobile_app/_global.css +++ b/app/assets/stylesheets/mobile_app/_global.css @@ -1,6 +1,3 @@ @layer mobile_app { /* Mobile app only styles go here */ - * { - color: red !important; - } } From 27556448c4d3be006ae177726726409935dd1ef6 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Mon, 24 Nov 2025 12:07:15 +0100 Subject: [PATCH 019/318] Fix icon paths --- app/assets/stylesheets/web/icons.css | 174 ++++++++++++------------ app/assets/stylesheets/web/markdown.css | 2 +- 2 files changed, 88 insertions(+), 88 deletions(-) diff --git a/app/assets/stylesheets/web/icons.css b/app/assets/stylesheets/web/icons.css index 8e9f19e89..6d1697bf9 100644 --- a/app/assets/stylesheets/web/icons.css +++ b/app/assets/stylesheets/web/icons.css @@ -18,91 +18,91 @@ background: none; } - .icon--37signals { --svg: url("37signals.svg"); } - .icon--add { --svg: url("add.svg "); } - .icon--add--meta { --svg: url("add--meta.svg "); } - .icon--arrow-left { --svg: url("arrow-left.svg "); } - .icon--arrow-right { --svg: url("arrow-right.svg "); } - .icon--arrow-up { --svg: url("arrow-up.svg "); } - .icon--art { --svg: url("art.svg "); } - .icon--assigned { --svg: url("assigned.svg "); } - .icon--attachment { --svg: url("attachment.svg "); } - .icon--bell-alert { --svg: url("bell-alert.svg "); } - .icon--bell-off { --svg: url("bell-off.svg "); } - .icon--bell { --svg: url("bell.svg "); } - .icon--bolt { --svg: url("bolt.svg "); } - .icon--bookmark-outline { --svg: url("bookmark-outline.svg "); } - .icon--bookmark { --svg: url("bookmark.svg "); } - .icon--boost { --svg: url("boost.svg "); } - .icon--camera { --svg: url("camera.svg "); } - .icon--caret-down { --svg: url("caret-down.svg "); } - .icon--check { --svg: url("check.svg "); } - .icon--check-circle { --svg: url("check-circle.svg "); } - .icon--check-all { --svg: url("check-all.svg "); } - .icon--clipboard { --svg: url("clipboard.svg "); } - .icon--close { --svg: url("close.svg "); } - .icon--close-circle { --svg: url("close-circle.svg "); } - .icon--collapse { --svg: url("collapse.svg "); } - .icon--board { --svg: url("board.svg "); } - .icon--board-add { --svg: url("board-add.svg "); } - .icon--column-left { --svg: url("column-left.svg "); } - .icon--column-right { --svg: url("column-right.svg "); } - .icon--comment { --svg: url("comment.svg "); } - .icon--copy-paste { --svg: url("copy-paste.svg "); } - .icon--crown { --svg: url("crown.svg "); } - .icon--email { --svg: url("email.svg "); } - .icon--everyone { --svg: url("everyone.svg "); } - .icon--expand { --svg: url("expand.svg "); } - .icon--gear { --svg: url("gear.svg "); } - .icon--grid { --svg: url("grid.svg "); } - .icon--filter { --svg: url("filter.svg "); } - .icon--fizzy { --svg: url("fizzy.svg"); } - .icon--globe { --svg: url("globe.svg "); } - .icon--golden-ticket { --svg: url("golden-ticket.svg "); } - .icon--home { --svg: url("home.svg "); } - .icon--install { --svg: url("install.svg "); } - .icon--install { --svg: url("install.svg "); } - .icon--install-edge { --svg: url("install-edge.svg "); } - .icon--lifebuoy { --svg: url("lifebuoy.svg "); } - .icon--lock { --svg: url("lock.svg "); } - .icon--logout { --svg: url("logout.svg "); } - .icon--marker { --svg: url("marker.svg "); } - .icon--maximize { --svg: url("maximize.svg "); } - .icon--menu { --svg: url("menu.svg "); } - .icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); } - .icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); } - .icon--minus { --svg: url("minus.svg "); } - .icon--move { --svg: url("move.svg "); } - .icon--notification-bell-access-only { --svg: url("bell.svg "); } - .icon--notification-bell-watching { --svg: url("bell-off.svg "); } - .icon--notification-bell-reverse-access-only { --svg: url("bell-off.svg "); } - .icon--notification-bell-reverse-watching { --svg: url("bell.svg "); } - .icon--password { --svg: url("password.svg "); } - .icon--pencil { --svg: url("pencil.svg "); } - .icon--person { --svg: url("person.svg "); } - .icon--person-add { --svg: url("person-add.svg "); } - .icon--picture-add { --svg: url("picture-add.svg "); } - .icon--picture-double { --svg: url("picture-double.svg "); } - .icon--picture-remove { --svg: url("picture-remove.svg "); } - .icon--picture-zoom { --svg: url("picture-zoom.svg "); } - .icon--pinned { --svg: url("pinned.svg "); } - .icon--qr-code { --svg: url("qr-code.svg "); } - .icon--reaction { --svg: url("reaction.svg "); } - .icon--refresh { --svg: url("refresh.svg "); } - .icon--refresh--meta { --svg: url("refresh--meta.svg "); } - .icon--remove { --svg: url("remove.svg "); } - .icon--rename { --svg: url("rename.svg "); } - .icon--search { --svg: url("search.svg "); } - .icon--settings { --svg: url("settings.svg "); } - .icon--share { --svg: url("share.svg "); } - .icon--sliders { --svg: url("sliders.svg "); } - .icon--switch { --svg: url("switch.svg "); } - .icon--tag { --svg: url("tag.svg "); } - .icon--tag-outline { --svg: url("tag-outline.svg "); } - .icon--thumb-up { --svg: url("thumb-up.svg "); } - .icon--trash { --svg: url("trash.svg "); } - .icon--unpinned { --svg: url("unpinned.svg"); } - .icon--unseen { --svg: url("unseen.svg"); } - .icon--world { --svg: url("world.svg"); } - .icon--youtube { --svg: url("youtube.svg"); } + .icon--37signals { --svg: url("/37signals.svg"); } + .icon--add { --svg: url("/add.svg"); } + .icon--add--meta { --svg: url("/add--meta.svg"); } + .icon--arrow-left { --svg: url("/arrow-left.svg"); } + .icon--arrow-right { --svg: url("/arrow-right.svg"); } + .icon--arrow-up { --svg: url("/arrow-up.svg"); } + .icon--art { --svg: url("/art.svg"); } + .icon--assigned { --svg: url("/assigned.svg"); } + .icon--attachment { --svg: url("/attachment.svg"); } + .icon--bell-alert { --svg: url("/bell-alert.svg"); } + .icon--bell-off { --svg: url("/bell-off.svg"); } + .icon--bell { --svg: url("/bell.svg"); } + .icon--bolt { --svg: url("/bolt.svg"); } + .icon--bookmark-outline { --svg: url("/bookmark-outline.svg"); } + .icon--bookmark { --svg: url("/bookmark.svg"); } + .icon--boost { --svg: url("/boost.svg"); } + .icon--camera { --svg: url("/camera.svg"); } + .icon--caret-down { --svg: url("/caret-down.svg"); } + .icon--check { --svg: url("/check.svg"); } + .icon--check-circle { --svg: url("/check-circle.svg"); } + .icon--check-all { --svg: url("/check-all.svg"); } + .icon--clipboard { --svg: url("/clipboard.svg"); } + .icon--close { --svg: url("/close.svg"); } + .icon--close-circle { --svg: url("/close-circle.svg"); } + .icon--collapse { --svg: url("/collapse.svg"); } + .icon--board { --svg: url("/board.svg"); } + .icon--board-add { --svg: url("/board-add.svg"); } + .icon--column-left { --svg: url("/column-left.svg"); } + .icon--column-right { --svg: url("/column-right.svg"); } + .icon--comment { --svg: url("/comment.svg"); } + .icon--copy-paste { --svg: url("/copy-paste.svg"); } + .icon--crown { --svg: url("/crown.svg"); } + .icon--email { --svg: url("/email.svg"); } + .icon--everyone { --svg: url("/everyone.svg"); } + .icon--expand { --svg: url("/expand.svg"); } + .icon--gear { --svg: url("/gear.svg"); } + .icon--grid { --svg: url("/grid.svg"); } + .icon--filter { --svg: url("/filter.svg"); } + .icon--fizzy { --svg: url("/fizzy.svg"); } + .icon--globe { --svg: url("/globe.svg"); } + .icon--golden-ticket { --svg: url("/golden-ticket.svg"); } + .icon--home { --svg: url("/home.svg"); } + .icon--install { --svg: url("/install.svg"); } + .icon--install { --svg: url("/install.svg"); } + .icon--install-edge { --svg: url("/install-edge.svg"); } + .icon--lifebuoy { --svg: url("/lifebuoy.svg"); } + .icon--lock { --svg: url("/lock.svg"); } + .icon--logout { --svg: url("/logout.svg"); } + .icon--marker { --svg: url("/marker.svg"); } + .icon--maximize { --svg: url("/maximize.svg"); } + .icon--menu { --svg: url("/menu.svg"); } + .icon--menu-dots-horizontal { --svg: url("/menu-dots-horizontal.svg"); } + .icon--menu-dots-vertical { --svg: url("/menu-dots-vertical.svg"); } + .icon--minus { --svg: url("/minus.svg"); } + .icon--move { --svg: url("/move.svg"); } + .icon--notification-bell-access-only { --svg: url("/bell.svg"); } + .icon--notification-bell-watching { --svg: url("/bell-off.svg"); } + .icon--notification-bell-reverse-access-only { --svg: url("/bell-off.svg"); } + .icon--notification-bell-reverse-watching { --svg: url("/bell.svg"); } + .icon--password { --svg: url("/password.svg"); } + .icon--pencil { --svg: url("/pencil.svg"); } + .icon--person { --svg: url("/person.svg"); } + .icon--person-add { --svg: url("/person-add.svg"); } + .icon--picture-add { --svg: url("/picture-add.svg"); } + .icon--picture-double { --svg: url("/picture-double.svg"); } + .icon--picture-remove { --svg: url("/picture-remove.svg"); } + .icon--picture-zoom { --svg: url("/picture-zoom.svg"); } + .icon--pinned { --svg: url("/pinned.svg"); } + .icon--qr-code { --svg: url("/qr-code.svg"); } + .icon--reaction { --svg: url("/reaction.svg"); } + .icon--refresh { --svg: url("/refresh.svg"); } + .icon--refresh--meta { --svg: url("/refresh--meta.svg"); } + .icon--remove { --svg: url("/remove.svg"); } + .icon--rename { --svg: url("/rename.svg"); } + .icon--search { --svg: url("/search.svg"); } + .icon--settings { --svg: url("/settings.svg"); } + .icon--share { --svg: url("/share.svg"); } + .icon--sliders { --svg: url("/sliders.svg"); } + .icon--switch { --svg: url("/switch.svg"); } + .icon--tag { --svg: url("/tag.svg"); } + .icon--tag-outline { --svg: url("/tag-outline.svg"); } + .icon--thumb-up { --svg: url("/thumb-up.svg"); } + .icon--trash { --svg: url("/trash.svg"); } + .icon--unpinned { --svg: url("/unpinned.svg"); } + .icon--unseen { --svg: url("/unseen.svg"); } + .icon--world { --svg: url("/world.svg"); } + .icon--youtube { --svg: url("/youtube.svg"); } } diff --git a/app/assets/stylesheets/web/markdown.css b/app/assets/stylesheets/web/markdown.css index 5dfac832c..81147fc97 100644 --- a/app/assets/stylesheets/web/markdown.css +++ b/app/assets/stylesheets/web/markdown.css @@ -3,7 +3,7 @@ --opacity: 0.5; --size: 0.8em; - background: url(link.svg) no-repeat center bottom 0.2em; + background: url("/link.svg") no-repeat center bottom 0.2em; background-size: var(--size); block-size: 1em; color: var(--color-link); From 38d0e362aa14c759dff276619856ec33316afde4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 24 Nov 2025 15:16:17 +0100 Subject: [PATCH 020/318] Move test to the private gem --- saas/test/controllers/.keep | 0 .../controllers/sessions_controller_test.rb | 21 +++++++++++++++++++ 2 files changed, 21 insertions(+) delete mode 100644 saas/test/controllers/.keep create mode 100644 saas/test/controllers/sessions_controller_test.rb diff --git a/saas/test/controllers/.keep b/saas/test/controllers/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/saas/test/controllers/sessions_controller_test.rb b/saas/test/controllers/sessions_controller_test.rb new file mode 100644 index 000000000..14892d34e --- /dev/null +++ b/saas/test/controllers/sessions_controller_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + test "create for a new user" do + assert_difference -> { Identity.count }, +1 do + assert_difference -> { MagicLink.count }, +1 do + post session_path, + params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }, + headers: http_basic_auth_headers("testname", "testpassword") + end + end + + assert_redirected_to session_magic_link_path + end + + private + def http_basic_auth_headers(user, password) + { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials(user, password) } + end +end + From 2232caf7782d943d2187e9a19cb92caf1ffa632c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 24 Nov 2025 15:17:39 +0100 Subject: [PATCH 021/318] Remove empty dir --- saas/app/controllers/concerns/.keep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 saas/app/controllers/concerns/.keep diff --git a/saas/app/controllers/concerns/.keep b/saas/app/controllers/concerns/.keep deleted file mode 100644 index e69de29bb..000000000 From 2e4478ffe59992e4547bc871064b0eb999610642 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 24 Nov 2025 15:43:29 +0100 Subject: [PATCH 022/318] Remove empty dir --- .../saas_sessions_controller_test.rb | 24 +++++++++++++++++++ .../controllers/sessions_controller_test.rb | 21 ---------------- 2 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 saas/test/controllers/saas_sessions_controller_test.rb delete mode 100644 saas/test/controllers/sessions_controller_test.rb diff --git a/saas/test/controllers/saas_sessions_controller_test.rb b/saas/test/controllers/saas_sessions_controller_test.rb new file mode 100644 index 000000000..2fa4ab0f1 --- /dev/null +++ b/saas/test/controllers/saas_sessions_controller_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class SaasSessionsControllerTest < ActionDispatch::IntegrationTest + test "create for a new user" do + untenanted do + assert_difference -> { Identity.count }, +1 do + assert_difference -> { MagicLink.count }, +1 do + puts "BEFORE!" + post session_path, + params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }, + headers: http_basic_auth_headers("testname", "testpassword") + end + end + + assert_redirected_to session_magic_link_path + end + end + + private + def http_basic_auth_headers(user, password) + { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials(user, password) } + end +end + diff --git a/saas/test/controllers/sessions_controller_test.rb b/saas/test/controllers/sessions_controller_test.rb deleted file mode 100644 index 14892d34e..000000000 --- a/saas/test/controllers/sessions_controller_test.rb +++ /dev/null @@ -1,21 +0,0 @@ -require "test_helper" - -class SessionsControllerTest < ActionDispatch::IntegrationTest - test "create for a new user" do - assert_difference -> { Identity.count }, +1 do - assert_difference -> { MagicLink.count }, +1 do - post session_path, - params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }, - headers: http_basic_auth_headers("testname", "testpassword") - end - end - - assert_redirected_to session_magic_link_path - end - - private - def http_basic_auth_headers(user, password) - { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials(user, password) } - end -end - From 73be889a185b3a19803f1d01ebd04d45c334ba7d Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Mon, 24 Nov 2025 14:49:27 +0000 Subject: [PATCH 023/318] Include transaction pinning support See https://github.com/basecamp/fizzy/pull/1656 --- saas/lib/fizzy/saas/engine.rb | 7 +++ saas/lib/fizzy/saas/metrics.rb | 13 +++++ saas/lib/fizzy/saas/transaction_pinning.rb | 65 ++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 saas/lib/fizzy/saas/metrics.rb create mode 100644 saas/lib/fizzy/saas/transaction_pinning.rb diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index af56f1ed9..c804c38d1 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -1,3 +1,6 @@ +require_relative "metrics" +require_relative "transaction_pinning" + module Fizzy module Saas class Engine < ::Rails::Engine @@ -10,6 +13,10 @@ module Fizzy end end + initializer "fizzy_saas.transaction_pinning" do |app| + app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) + end + config.to_prepare do Queenbee::Subscription.short_names = Subscription::SHORT_NAMES Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) diff --git a/saas/lib/fizzy/saas/metrics.rb b/saas/lib/fizzy/saas/metrics.rb new file mode 100644 index 000000000..80a2bc194 --- /dev/null +++ b/saas/lib/fizzy/saas/metrics.rb @@ -0,0 +1,13 @@ +Yabeda.configure do + SHORT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5 ] + + group :fizzy do + counter :replica_stale, + comment: "Number of requests served from a stale replica" + + histogram :replica_wait, + unit: :seconds, + comment: "Time spent waiting for replica to catch up with transaction", + buckets: SHORT_HISTOGRAM_BUCKETS + end +end diff --git a/saas/lib/fizzy/saas/transaction_pinning.rb b/saas/lib/fizzy/saas/transaction_pinning.rb new file mode 100644 index 000000000..ed3cf2060 --- /dev/null +++ b/saas/lib/fizzy/saas/transaction_pinning.rb @@ -0,0 +1,65 @@ +module TransactionPinning + class Middleware + SESSION_KEY = :last_txn + DEFAULT_MAX_WAIT = 0.25 + + def initialize(app) + @app = app + @timeout = Rails.application.config.x.transaction_pinning&.timeout&.to_f || DEFAULT_MAX_WAIT + end + + def call(env) + request = ActionDispatch::Request.new(env) + replica_metrics = {} + + if ApplicationRecord.current_role == :reading + wait_for_replica_catchup(request, replica_metrics) + end + + status, headers, body = @app.call(env) + headers.merge!(replica_metrics.transform_values(&:to_s)) + + if ApplicationRecord.current_role == :writing + capture_transaction_id(request) + end + + [ status, headers, body ] + end + + private + def wait_for_replica_catchup(request, replica_metrics) + if last_txn = request.session[SESSION_KEY].presence + has_transaction = tracking_replica_wait_time(replica_metrics) do + replica_has_transaction(last_txn) + end + + unless has_transaction + Yabeda.fizzy.replica_stale.increment + replica_metrics["X-Replica-Stale"] = true + end + end + end + + def capture_transaction_id(request) + request.session[SESSION_KEY] = ApplicationRecord.connection.show_variable("global.gtid_executed") + end + + def replica_has_transaction(txn) + sql = ApplicationRecord.sanitize_sql_array([ "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)", txn, @timeout ]) + ApplicationRecord.connection.select_value(sql) == 0 + rescue => e + Sentry.capture_exception(e, extra: { gtid: txn }) + true # Treat as if we're up to date, since we don't know + end + + def tracking_replica_wait_time(replica_metrics) + started_at = Time.current + + Yabeda.fizzy.replica_wait.measure do + yield + end.tap do + replica_metrics["X-Replica-Wait"] = Time.current - started_at + end + end + end +end From 8019aa154fa27c69915c6325fe74007648bd5fba Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 24 Nov 2025 15:58:57 +0100 Subject: [PATCH 024/318] Stub QB requests in tests --- saas/test/controllers/saas_sessions_controller_test.rb | 1 - saas/test/controllers/signups/completions_controller_test.rb | 2 ++ saas/test/models/signup_test.rb | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/saas/test/controllers/saas_sessions_controller_test.rb b/saas/test/controllers/saas_sessions_controller_test.rb index 2fa4ab0f1..639d6353e 100644 --- a/saas/test/controllers/saas_sessions_controller_test.rb +++ b/saas/test/controllers/saas_sessions_controller_test.rb @@ -5,7 +5,6 @@ class SaasSessionsControllerTest < ActionDispatch::IntegrationTest untenanted do assert_difference -> { Identity.count }, +1 do assert_difference -> { MagicLink.count }, +1 do - puts "BEFORE!" post session_path, params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }, headers: http_basic_auth_headers("testname", "testpassword") diff --git a/saas/test/controllers/signups/completions_controller_test.rb b/saas/test/controllers/signups/completions_controller_test.rb index 49628a188..955300f55 100644 --- a/saas/test/controllers/signups/completions_controller_test.rb +++ b/saas/test/controllers/signups/completions_controller_test.rb @@ -18,6 +18,8 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest end test "create" do + Queenbee::Remote::Account.any_instance.stubs(:save).returns(true) + untenanted do post saas.signup_completion_path, params: { signup: { diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb index 335a1f0eb..d34518322 100644 --- a/saas/test/models/signup_test.rb +++ b/saas/test/models/signup_test.rb @@ -30,6 +30,8 @@ class SignupTest < ActiveSupport::TestCase test "#complete" do ::Account.any_instance.expects(:setup_customer_template).once + Queenbee::Remote::Account.any_instance.stubs(:save).returns(true) + ::Current.without_account do signup = Signup.new( full_name: "Kevin", From a23ea87ce5b986beef689a2e5d5f8a6cbac91343 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 24 Nov 2025 15:59:01 +0100 Subject: [PATCH 025/318] bundle --- saas/Gemfile.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock index a7c5d9ab5..2adbd96d7 100644 --- a/saas/Gemfile.lock +++ b/saas/Gemfile.lock @@ -146,6 +146,8 @@ GEM nio4r (2.7.5) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -216,6 +218,7 @@ GEM PLATFORMS arm64-darwin + x86_64-linux DEPENDENCIES activeresource From ebc212c1d7991ae534f87e041a92a8af79e6f2b5 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 24 Nov 2025 16:26:41 +0100 Subject: [PATCH 026/318] Fix: the queenbe mocks were not really being applied --- saas/lib/fizzy/saas/engine.rb | 7 +++++++ saas/{test/test_helper.rb => lib/fizzy/saas/testing.rb} | 0 .../controllers/signups/completions_controller_test.rb | 2 -- saas/test/models/signup_test.rb | 1 - 4 files changed, 7 insertions(+), 3 deletions(-) rename saas/{test/test_helper.rb => lib/fizzy/saas/testing.rb} (100%) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index c804c38d1..653d20fdb 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -17,6 +17,13 @@ module Fizzy app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end + # Load test mocks automatically in test environment + initializer "fizzy_saas.test_mocks", after: :load_config_initializers do + if Rails.env.test? + require "fizzy/saas/testing" + end + end + config.to_prepare do Queenbee::Subscription.short_names = Subscription::SHORT_NAMES Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) diff --git a/saas/test/test_helper.rb b/saas/lib/fizzy/saas/testing.rb similarity index 100% rename from saas/test/test_helper.rb rename to saas/lib/fizzy/saas/testing.rb diff --git a/saas/test/controllers/signups/completions_controller_test.rb b/saas/test/controllers/signups/completions_controller_test.rb index 955300f55..49628a188 100644 --- a/saas/test/controllers/signups/completions_controller_test.rb +++ b/saas/test/controllers/signups/completions_controller_test.rb @@ -18,8 +18,6 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest end test "create" do - Queenbee::Remote::Account.any_instance.stubs(:save).returns(true) - untenanted do post saas.signup_completion_path, params: { signup: { diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb index d34518322..0751f4c91 100644 --- a/saas/test/models/signup_test.rb +++ b/saas/test/models/signup_test.rb @@ -30,7 +30,6 @@ class SignupTest < ActiveSupport::TestCase test "#complete" do ::Account.any_instance.expects(:setup_customer_template).once - Queenbee::Remote::Account.any_instance.stubs(:save).returns(true) ::Current.without_account do signup = Signup.new( From 034b1629914974adc70a0e570ed03069a957bcad Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 24 Nov 2025 15:57:16 -0500 Subject: [PATCH 027/318] Restore structured logging of `queenbee_id` --- saas/lib/fizzy/saas/engine.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 653d20fdb..d5df82287 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -24,6 +24,16 @@ module Fizzy end end + initializer "fizzy_saas.logging.queenbee_id" do |app| + ActiveSupport.on_load(:action_controller_base) do + before_action do + if Current.account.present? + logger.try(:struct, account: { queenbee_id: Current.account.external_account_id }) + end + end + end + end + config.to_prepare do Queenbee::Subscription.short_names = Subscription::SHORT_NAMES Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) From a6e5f18f4068009695f52d694c4198ac375c7945 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 25 Nov 2025 13:00:42 +0100 Subject: [PATCH 028/318] Include tests since we want to run them via the host app --- saas/fizzy-saas.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index f1b28de00..890b6fec2 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |spec| spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] + Dir["{app,config,db,lib,test}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] end spec.add_dependency "rails", ">= 8.1.0.beta1" From 5e3c9c1d9274710e98ddff29e4d285092cf88db7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 25 Nov 2025 13:05:06 +0100 Subject: [PATCH 029/318] Add method to invoke from the rails runner --- saas/lib/fizzy/saas.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/saas/lib/fizzy/saas.rb b/saas/lib/fizzy/saas.rb index dd0d35492..bda086997 100644 --- a/saas/lib/fizzy/saas.rb +++ b/saas/lib/fizzy/saas.rb @@ -3,5 +3,10 @@ require "fizzy/saas/engine" module Fizzy module Saas + def self.append_test_paths + saas_test_dir = "#{Gem::Specification.find_by_name("fizzy-saas").gem_dir}/test" + ENV["DEFAULT_TEST"] = "{#{saas_test_dir},test}/**/*_test.rb" + ENV["DEFAULT_TEST_EXCLUDE"] = "{#{saas_test_dir},test}/{system,dummy,fixtures}/**/*_test.rb" + end end end From e6a015b931c34bc349bb49bea19ad15eaeaf12a9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 25 Nov 2025 13:09:49 +0100 Subject: [PATCH 030/318] Prefer path via the engine --- saas/lib/fizzy/saas.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/saas/lib/fizzy/saas.rb b/saas/lib/fizzy/saas.rb index bda086997..ab6aef29b 100644 --- a/saas/lib/fizzy/saas.rb +++ b/saas/lib/fizzy/saas.rb @@ -4,9 +4,9 @@ require "fizzy/saas/engine" module Fizzy module Saas def self.append_test_paths - saas_test_dir = "#{Gem::Specification.find_by_name("fizzy-saas").gem_dir}/test" - ENV["DEFAULT_TEST"] = "{#{saas_test_dir},test}/**/*_test.rb" - ENV["DEFAULT_TEST_EXCLUDE"] = "{#{saas_test_dir},test}/{system,dummy,fixtures}/**/*_test.rb" + engine_test_path = Engine.root.join("test") + ENV["DEFAULT_TEST"] = "{#{engine_test_path},test}/**/*_test.rb" + ENV["DEFAULT_TEST_EXCLUDE"] = "{#{engine_test_path},test}/{system,dummy,fixtures}/**/*_test.rb" end end end From 8f27cb80e102346de57ccf7fb13735f3557aea81 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 25 Nov 2025 13:12:49 +0000 Subject: [PATCH 031/318] Set MySQL max execution time of 5s --- saas/config/database.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/saas/config/database.yml b/saas/config/database.yml index 3c68fd081..3ef8f4b53 100644 --- a/saas/config/database.yml +++ b/saas/config/database.yml @@ -2,9 +2,11 @@ if ENV["MIGRATE"].present? mysql_app_user_key = "MYSQL_ALTER_USER" mysql_app_password_key = "MYSQL_ALTER_PASSWORD" + max_execution_time_ms = 0 # No limit else mysql_app_user_key = "MYSQL_APP_USER" mysql_app_password_key = "MYSQL_APP_PASSWORD" + max_execution_time_ms = 5_000 end mysql_app_user = ENV[mysql_app_user_key] @@ -19,6 +21,7 @@ default: &default timeout: 5000 variables: transaction_isolation: READ-COMMITTED + max_execution_time: <%= max_execution_time_ms %> development: primary: From cba3c68c239c3fb1117917a7f5eab88148a45d4c Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 26 Nov 2025 10:25:54 +0100 Subject: [PATCH 032/318] Add utilities files and insets --- app/assets/stylesheets/android/_global.css | 3 --- app/assets/stylesheets/android/utilities.css | 5 +++++ app/assets/stylesheets/ios/_global.css | 3 --- app/assets/stylesheets/ios/utilities.css | 5 +++++ app/assets/stylesheets/mobile_app/_global.css | 9 ++++++++- app/assets/stylesheets/mobile_app/utilities.css | 9 +++++++++ 6 files changed, 27 insertions(+), 7 deletions(-) delete mode 100644 app/assets/stylesheets/android/_global.css create mode 100644 app/assets/stylesheets/android/utilities.css delete mode 100644 app/assets/stylesheets/ios/_global.css create mode 100644 app/assets/stylesheets/ios/utilities.css create mode 100644 app/assets/stylesheets/mobile_app/utilities.css diff --git a/app/assets/stylesheets/android/_global.css b/app/assets/stylesheets/android/_global.css deleted file mode 100644 index 247ac7ba0..000000000 --- a/app/assets/stylesheets/android/_global.css +++ /dev/null @@ -1,3 +0,0 @@ -@layer android { - /* Android app only styles go here */ -} diff --git a/app/assets/stylesheets/android/utilities.css b/app/assets/stylesheets/android/utilities.css new file mode 100644 index 000000000..e0d03f570 --- /dev/null +++ b/app/assets/stylesheets/android/utilities.css @@ -0,0 +1,5 @@ +@layer android { + .hide-in-android-app { + display: none; + } +} diff --git a/app/assets/stylesheets/ios/_global.css b/app/assets/stylesheets/ios/_global.css deleted file mode 100644 index be4178b36..000000000 --- a/app/assets/stylesheets/ios/_global.css +++ /dev/null @@ -1,3 +0,0 @@ -@layer ios { - /* iOS app only styles go here */ -} diff --git a/app/assets/stylesheets/ios/utilities.css b/app/assets/stylesheets/ios/utilities.css new file mode 100644 index 000000000..0dfeada57 --- /dev/null +++ b/app/assets/stylesheets/ios/utilities.css @@ -0,0 +1,5 @@ +@layer ios { + .hide-in-ios-app { + display: none; + } +} diff --git a/app/assets/stylesheets/mobile_app/_global.css b/app/assets/stylesheets/mobile_app/_global.css index f3ee25327..5d0648df5 100644 --- a/app/assets/stylesheets/mobile_app/_global.css +++ b/app/assets/stylesheets/mobile_app/_global.css @@ -1,3 +1,10 @@ @layer mobile_app { - /* Mobile app only styles go here */ + /* Use custom insets to account for floating navigation elements. + The mobile apps can inject their own inset value based on native elements on screen. */ + :root { + --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); + --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); + --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); + --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); + } } diff --git a/app/assets/stylesheets/mobile_app/utilities.css b/app/assets/stylesheets/mobile_app/utilities.css new file mode 100644 index 000000000..85e9a9848 --- /dev/null +++ b/app/assets/stylesheets/mobile_app/utilities.css @@ -0,0 +1,9 @@ +@layer mobile_app { + .hide-in-mobile-app { + display: none; + } + + .show-in-mobile-app { + display: unset; + } +} From bdfdb5bf84bf2ce2bfab2eb6c8eb5b86f4b62ed8 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 26 Nov 2025 10:37:01 +0100 Subject: [PATCH 033/318] Hide mobile-apps only elements on web --- app/assets/stylesheets/web/utilities.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/web/utilities.css b/app/assets/stylesheets/web/utilities.css index 5b73e1266..05376014a 100644 --- a/app/assets/stylesheets/web/utilities.css +++ b/app/assets/stylesheets/web/utilities.css @@ -250,6 +250,10 @@ } } + .show-in-mobile-app { + display: none; + } + .hide-scrollbar { -ms-overflow-style: none; /* Edge */ scrollbar-width: none; /* FF */ From cb76bf10e2d58d6fc1199659cce8d09efa07bb3f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 13:00:09 +0100 Subject: [PATCH 034/318] No need to use try here --- saas/lib/fizzy/saas/engine.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index d5df82287..09f571131 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -28,7 +28,7 @@ module Fizzy ActiveSupport.on_load(:action_controller_base) do before_action do if Current.account.present? - logger.try(:struct, account: { queenbee_id: Current.account.external_account_id }) + logger.struct(account: { queenbee_id: Current.account.external_account_id }) end end end From 13bd530702e817263b28d208664d309a83c59827 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 14:00:08 +0100 Subject: [PATCH 035/318] Move secrets --- saas/config/deploy.production.yml | 3 +++ saas/config/deploy.staging.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index 285dab819..a26c9419e 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -37,6 +37,9 @@ env: - MYSQL_APP_USER - MYSQL_READONLY_PASSWORD - MYSQL_READONLY_USER + - QUEENBEE_API_TOKEN + - VAPID_PUBLIC_KEY + - VAPID_PRIVATE_KEY tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index 3163c7a7b..84ed66fe4 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -37,6 +37,9 @@ env: - MYSQL_APP_USER - MYSQL_READONLY_PASSWORD - MYSQL_READONLY_USER + - QUEENBEE_API_TOKEN + - VAPID_PUBLIC_KEY + - VAPID_PRIVATE_KEY tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com From eef94b0442cc90883d96eb5a8e29f1843734a120 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 14:01:02 +0100 Subject: [PATCH 036/318] Remove unused setup --- saas/config/deploy.dhh.yml | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 saas/config/deploy.dhh.yml diff --git a/saas/config/deploy.dhh.yml b/saas/config/deploy.dhh.yml deleted file mode 100644 index 05bf874d4..000000000 --- a/saas/config/deploy.dhh.yml +++ /dev/null @@ -1,12 +0,0 @@ -servers: - web: - hosts: - - test-1 - jobs: - hosts: - - test-1 -env: - clear: - ARTENANT: test - -proxy: false From fa340579b94f8eca08bba138104ecb42489f7743 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 14:01:11 +0100 Subject: [PATCH 037/318] Remove from credentials --- saas/lib/fizzy/saas/engine.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 09f571131..288f5e0c3 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -36,7 +36,9 @@ module Fizzy config.to_prepare do Queenbee::Subscription.short_names = Subscription::SHORT_NAMES - Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) + + # Default to dev QB token if not set + Queenbee::ApiToken.token = ENV.fetch("QUEENBEE_API_TOKEN") { "69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1" } Subscription::SHORT_NAMES.each do |short_name| const_name = "#{short_name}Subscription" From ebd4e0dbd78224de554e9847d92d0322d28fd323 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 17:04:37 +0100 Subject: [PATCH 038/318] Move storage from main fizzy app --- saas/config/storage.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 saas/config/storage.yml diff --git a/saas/config/storage.yml b/saas/config/storage.yml new file mode 100644 index 000000000..a149ccf7b --- /dev/null +++ b/saas/config/storage.yml @@ -0,0 +1,33 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage/files") %> + +local: + service: Disk + root: <%= Rails.root.join("storage", Rails.env, "files") %> + +devminio: + service: S3 + bucket: fizzy-dev-activestorage + endpoint: "http://minio.localhost:39000" + force_path_style: true + request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + region: us-east-1 # default region required for signer + access_key_id: minioadmin + secret_access_key: minioadmin + +# We have "development", "staging", and "production" buckets configured. Note that we don't have a +# "beta" bucket. (As of 2025-06-01.) +<% pure_env = Rails.env.beta? ? "production" : Rails.env %> +purestorage: + service: S3 + bucket: fizzy-<%= pure_env %>-activestorage + endpoint: "https://storage.basecamp.com" + ssl_verify_peer: false # FIXME: using self-signed cert internally + force_path_style: true + request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + region: us-east-1 # default region required for signer + access_key_id: <%= ENV["ACTIVE_STORAGE_ACCESS_KEY_ID"] %> + secret_access_key: <%= ENV["ACTIVE_STORAGE_SECRET_ACCESS_KEY"] %> From d8ab713c7ba3bf26bd6c342784abf1bb71dfe7da Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 17:10:02 +0100 Subject: [PATCH 039/318] Add missing kamal secrets --- saas/config/deploy.beta.yml | 20 ++++++++++++++++++++ saas/config/deploy.production.yml | 6 +++++- saas/config/deploy.staging.yml | 6 +++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index f1db401c7..2f5c1a4ed 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -20,6 +20,26 @@ ssh: env: clear: RAILS_ENV: beta + MYSQL_DATABASE_HOST: fizzy-beta-mysql-primary + MYSQL_DATABASE_REPLICA_HOST: fizzy-beta-mysql-replica + MYSQL_SOLID_CABLE_HOST: fizzy-beta-mysql-primary + MYSQL_SOLID_QUEUE_HOST: fizzy-beta-mysql-primary + MYSQL_SOLID_CACHE_HOST: fizzy-beta-mysql-primary + secret: + - RAILS_MASTER_KEY + - MYSQL_ALTER_PASSWORD + - MYSQL_ALTER_USER + - MYSQL_APP_PASSWORD + - MYSQL_APP_USER + - MYSQL_READONLY_PASSWORD + - MYSQL_READONLY_USER + - SECRET_KEY_BASE + - VAPID_PUBLIC_KEY + - VAPID_PRIVATE_KEY + - ACTIVE_STORAGE_ACCESS_KEY_ID + - ACTIVE_STORAGE_SECRET_ACCESS_KEY + - QUEENBEE_API_TOKEN + - SIGNAL_ID_SECRET accessories: load-balancer: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index a26c9419e..e5eb5a978 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -37,9 +37,13 @@ env: - MYSQL_APP_USER - MYSQL_READONLY_PASSWORD - MYSQL_READONLY_USER - - QUEENBEE_API_TOKEN + - SECRET_KEY_BASE - VAPID_PUBLIC_KEY - VAPID_PRIVATE_KEY + - ACTIVE_STORAGE_ACCESS_KEY_ID + - ACTIVE_STORAGE_SECRET_ACCESS_KEY + - QUEENBEE_API_TOKEN + - SIGNAL_ID_SECRET tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index 84ed66fe4..a9dedf657 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -37,9 +37,13 @@ env: - MYSQL_APP_USER - MYSQL_READONLY_PASSWORD - MYSQL_READONLY_USER - - QUEENBEE_API_TOKEN + - SECRET_KEY_BASE - VAPID_PUBLIC_KEY - VAPID_PRIVATE_KEY + - ACTIVE_STORAGE_ACCESS_KEY_ID + - ACTIVE_STORAGE_SECRET_ACCESS_KEY + - QUEENBEE_API_TOKEN + - SIGNAL_ID_SECRET tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com From 1cc3693635cf6bff4295063e7a555cbcad8ec3e4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 17:31:39 +0100 Subject: [PATCH 040/318] Move more settings to the gem --- saas/lib/fizzy/saas/engine.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 288f5e0c3..cba86d9a8 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -17,6 +17,17 @@ module Fizzy app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end + initializer "fizzy_saas.production_config", before: :load_config_initializers do |app| + if Rails.env.local? + if Rails.root.join("tmp/structured-logging.txt").exist? + app.config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") + end + else + app.config.active_storage.service = :purestorage + app.config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) + end + end + # Load test mocks automatically in test environment initializer "fizzy_saas.test_mocks", after: :load_config_initializers do if Rails.env.test? @@ -37,7 +48,7 @@ module Fizzy config.to_prepare do Queenbee::Subscription.short_names = Subscription::SHORT_NAMES - # Default to dev QB token if not set + # Default to local dev QB token if not set Queenbee::ApiToken.token = ENV.fetch("QUEENBEE_API_TOKEN") { "69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1" } Subscription::SHORT_NAMES.each do |short_name| From 2b711fc592b32fd81365b72a7b6a076320f4081c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 17:47:07 +0100 Subject: [PATCH 041/318] Moving kamal secrets here now that we can configure the path --- saas/.kamal/secrets.beta | 19 +++++++++++++++++++ saas/.kamal/secrets.production | 19 +++++++++++++++++++ saas/.kamal/secrets.staging | 19 +++++++++++++++++++ saas/config/deploy.yml | 1 + 4 files changed, 58 insertions(+) create mode 100644 saas/.kamal/secrets.beta create mode 100644 saas/.kamal/secrets.production create mode 100644 saas/.kamal/secrets.staging diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta new file mode 100644 index 000000000..2bd57d56c --- /dev/null +++ b/saas/.kamal/secrets.beta @@ -0,0 +1,19 @@ +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET) + +GITHUB_TOKEN=$(gh config get -h github.com oauth_token) +BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) +DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +VAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS) +VAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS) +ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS) +ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) +QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) +SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production new file mode 100644 index 000000000..e776444e7 --- /dev/null +++ b/saas/.kamal/secrets.production @@ -0,0 +1,19 @@ +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET) + +GITHUB_TOKEN=$(gh config get -h github.com oauth_token) +BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) +DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +VAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS) +VAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS) +ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS) +ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) +QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) +SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging new file mode 100644 index 000000000..8688d8ab9 --- /dev/null +++ b/saas/.kamal/secrets.staging @@ -0,0 +1,19 @@ +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET) + +GITHUB_TOKEN=$(gh config get -h github.com oauth_token) +BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) +DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +VAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS) +VAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS) +ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS) +ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) +QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) +SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) diff --git a/saas/config/deploy.yml b/saas/config/deploy.yml index 39d8d22b6..200c9e3e2 100644 --- a/saas/config/deploy.yml +++ b/saas/config/deploy.yml @@ -2,6 +2,7 @@ service: fizzy image: basecamp/fizzy asset_path: /rails/public/assets hooks_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal", "hooks") %> +secrets_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal") %> servers: jobs: From 5bdb19bdafa0f4440555c99ffc6eb62cf8112654 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 20:15:20 +0100 Subject: [PATCH 042/318] Fix secrets path --- saas/config/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/config/deploy.yml b/saas/config/deploy.yml index 200c9e3e2..71a7cabd7 100644 --- a/saas/config/deploy.yml +++ b/saas/config/deploy.yml @@ -2,7 +2,7 @@ service: fizzy image: basecamp/fizzy asset_path: /rails/public/assets hooks_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal", "hooks") %> -secrets_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal") %> +secrets_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal/secrets") %> servers: jobs: From 120ae3b9b6828e4c8a0be5dfe49590419fd79935 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 07:24:41 +0100 Subject: [PATCH 043/318] Move structured logging to the engine --- saas/lib/fizzy/saas/engine.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index cba86d9a8..3c8db7670 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -35,9 +35,13 @@ module Fizzy end end - initializer "fizzy_saas.logging.queenbee_id" do |app| + initializer "fizzy_saas.logging.session" do |app| ActiveSupport.on_load(:action_controller_base) do before_action do + if Current.identity.present? + logger.struct(" Authorized Identity##{Current.identity.id}", authentication: { identity: { id: Current.identity.id } }) + end + if Current.account.present? logger.struct(account: { queenbee_id: Current.account.external_account_id }) end From 1f230e3f57c1f11b614145cdf2522c982c619231 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 09:29:05 +0100 Subject: [PATCH 044/318] Add Sentry DSN via kamal secrets --- saas/.kamal/secrets.beta | 3 ++- saas/.kamal/secrets.production | 3 ++- saas/.kamal/secrets.staging | 3 ++- saas/config/deploy.beta.yml | 1 + saas/config/deploy.production.yml | 1 + saas/config/deploy.staging.yml | 1 + 6 files changed, 9 insertions(+), 3 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 2bd57d56c..ed17b6201 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -17,3 +17,4 @@ ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_I ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) +SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index e776444e7..acd4576fe 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -17,3 +17,4 @@ ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_I ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) +SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 8688d8ab9..421bc0cdf 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -17,3 +17,4 @@ ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_I ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) +SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 2f5c1a4ed..050bce25b 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -40,6 +40,7 @@ env: - ACTIVE_STORAGE_SECRET_ACCESS_KEY - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET + - SENTRY_DSN accessories: load-balancer: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index e5eb5a978..48f49f524 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -44,6 +44,7 @@ env: - ACTIVE_STORAGE_SECRET_ACCESS_KEY - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET + - SENTRY_DSN tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index a9dedf657..a0c777914 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -44,6 +44,7 @@ env: - ACTIVE_STORAGE_SECRET_ACCESS_KEY - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET + - SENTRY_DSN tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com From 3d849a202acd4960f2eb05a0671e58efe39130c4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 12:52:12 +0100 Subject: [PATCH 045/318] Move signup code back to the app, leave only queenbee hooks --- .../signup/completions_controller.rb | 24 -------- saas/config/routes.rb | 4 -- saas/lib/fizzy/saas/engine.rb | 3 + saas/lib/fizzy/saas/signup.rb | 44 +++++++++++++ ...er_test.rb => sessions_controller_test.rb} | 10 +-- .../signups/completions_controller_test.rb | 43 ------------- saas/test/models/.keep | 0 .../signup/account_name_generator_test.rb | 61 ------------------- saas/test/models/signup_test.rb | 60 ++++++++---------- 9 files changed, 73 insertions(+), 176 deletions(-) delete mode 100644 saas/app/controllers/signup/completions_controller.rb create mode 100644 saas/lib/fizzy/saas/signup.rb rename saas/test/controllers/{saas_sessions_controller_test.rb => sessions_controller_test.rb} (52%) delete mode 100644 saas/test/controllers/signups/completions_controller_test.rb delete mode 100644 saas/test/models/.keep delete mode 100644 saas/test/models/signup/account_name_generator_test.rb diff --git a/saas/app/controllers/signup/completions_controller.rb b/saas/app/controllers/signup/completions_controller.rb deleted file mode 100644 index d7f09c086..000000000 --- a/saas/app/controllers/signup/completions_controller.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Signup::CompletionsController < ApplicationController - layout "public" - - disallow_account_scope - - def new - @signup = Signup.new(identity: Current.identity) - end - - def create - @signup = Signup.new(signup_params) - - if @signup.complete - redirect_to landing_url(script_name: @signup.account.slug) - else - render :new, status: :unprocessable_entity - end - end - - private - def signup_params - params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity) - end -end diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 2b9639edb..1f720e87d 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -1,9 +1,5 @@ Fizzy::Saas::Engine.routes.draw do get "/signup/new", to: redirect("/session/new") - namespace :signup do - resource :completion, only: %i[ new create ] - end - Queenbee.routes(self) end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 3c8db7670..c0941f84e 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -1,5 +1,6 @@ require_relative "metrics" require_relative "transaction_pinning" +require_relative "signup" module Fizzy module Saas @@ -50,6 +51,8 @@ module Fizzy end config.to_prepare do + ::Signup.prepend(Fizzy::Saas::Signup) + Queenbee::Subscription.short_names = Subscription::SHORT_NAMES # Default to local dev QB token if not set diff --git a/saas/lib/fizzy/saas/signup.rb b/saas/lib/fizzy/saas/signup.rb new file mode 100644 index 000000000..e414d63f8 --- /dev/null +++ b/saas/lib/fizzy/saas/signup.rb @@ -0,0 +1,44 @@ +module Fizzy + module Saas + module Signup + extend ActiveSupport::Concern + + included do + attr_reader :queenbee_account + end + + private + def create_tenant + @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) + @queenbee_account.id.to_s + end + + def handle_account_creation_error(error) + @queenbee_account&.cancel + @queenbee_account = nil + end + + def queenbee_account_attributes + {}.tap do |attributes| + attributes[:product_name] = "fizzy" + attributes[:name] = generate_account_name + attributes[:owner_name] = full_name + attributes[:owner_email] = email_address + + attributes[:trial] = true + attributes[:subscription] = subscription_attributes + attributes[:remote_request] = request_attributes + + # # TODO: Terms of Service + # attributes[:terms_of_service] = true + + # We've confirmed the email + attributes[:auto_allow] = true + + # Tell Queenbee to skip the request to create a local account. We've created it ourselves. + attributes[:skip_remote] = true + end + end + end + end +end diff --git a/saas/test/controllers/saas_sessions_controller_test.rb b/saas/test/controllers/sessions_controller_test.rb similarity index 52% rename from saas/test/controllers/saas_sessions_controller_test.rb rename to saas/test/controllers/sessions_controller_test.rb index 639d6353e..6af58e63e 100644 --- a/saas/test/controllers/saas_sessions_controller_test.rb +++ b/saas/test/controllers/sessions_controller_test.rb @@ -1,23 +1,17 @@ require "test_helper" -class SaasSessionsControllerTest < ActionDispatch::IntegrationTest +class Fizzy::Saas::SessionsControllerTest < ActionDispatch::IntegrationTest test "create for a new user" do untenanted do assert_difference -> { Identity.count }, +1 do assert_difference -> { MagicLink.count }, +1 do post session_path, - params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }, - headers: http_basic_auth_headers("testname", "testpassword") + params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } end end assert_redirected_to session_magic_link_path end end - - private - def http_basic_auth_headers(user, password) - { "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials(user, password) } - end end diff --git a/saas/test/controllers/signups/completions_controller_test.rb b/saas/test/controllers/signups/completions_controller_test.rb deleted file mode 100644 index 49628a188..000000000 --- a/saas/test/controllers/signups/completions_controller_test.rb +++ /dev/null @@ -1,43 +0,0 @@ -require "test_helper" - -class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest - setup do - @signup = Signup.new(email_address: "newuser@example.com", full_name: "New User") - - @signup.create_identity || raise("Failed to create identity") - - sign_in_as @signup.identity - end - - test "new" do - untenanted do - get saas.new_signup_completion_path - end - - assert_response :success - end - - test "create" do - untenanted do - post saas.signup_completion_path, params: { - signup: { - full_name: @signup.full_name - } - } - end - - assert_response :redirect, "Valid params should redirect" - end - - test "create with invalid params" do - untenanted do - post saas.signup_completion_path, params: { - signup: { - full_name: "" - } - } - end - - assert_response :unprocessable_entity, "Invalid params should return unprocessable entity" - end -end diff --git a/saas/test/models/.keep b/saas/test/models/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/saas/test/models/signup/account_name_generator_test.rb b/saas/test/models/signup/account_name_generator_test.rb deleted file mode 100644 index e4d8e0d80..000000000 --- a/saas/test/models/signup/account_name_generator_test.rb +++ /dev/null @@ -1,61 +0,0 @@ -require "test_helper" - -class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase - setup do - @identity = ::Identity.create!(email_address: "newart.userbaum@example.com") - @name = "Newart userbaum" - @generator = Signup::AccountNameGenerator.new(identity: @identity, name: @name) - end - - test "generate" do - account_name = @generator.generate - assert_equal "Newart's Fizzy", account_name, "The 1st account doesn't have 1st in the name" - - first_account = ::Account.create!(external_account_id: "1st", name: account_name) - ::Current.without_account do - @identity.users.create!(account: first_account, name: @name) - @identity.reload - end - - account_name = @generator.generate - assert_equal "Newart's 2nd Fizzy", account_name - - second_account = ::Account.create!(external_account_id: "2nd", name: account_name) - ::Current.without_account do - @identity.users.create!(account: second_account, name: @name) - @identity.reload - end - - account_name = @generator.generate - assert_equal "Newart's 3rd Fizzy", account_name - - third_account = ::Account.create!(external_account_id: "3rd", name: account_name) - ::Current.without_account do - @identity.users.create!(account: third_account, name: @name) - @identity.reload - end - - account_name = @generator.generate - assert_equal "Newart's 4th Fizzy", account_name - - fourth_account = ::Account.create!(external_account_id: "4th", name: account_name) - ::Current.without_account do - @identity.users.create!(account: fourth_account, name: @name) - @identity.reload - end - - account_name = @generator.generate - assert_equal "Newart's 5th Fizzy", account_name - end - - test "generate continues from the previous highest index" do - account = ::Account.create!(external_account_id: "12th", name: "Newart's 12th Fizzy") - ::Current.without_account do - @identity.users.create!(account: account, name: @name) - @identity.reload - end - - account_name = @generator.generate - assert_equal "Newart's 13th Fizzy", account_name - end -end diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb index 0751f4c91..3754906af 100644 --- a/saas/test/models/signup_test.rb +++ b/saas/test/models/signup_test.rb @@ -1,37 +1,14 @@ require "test_helper" -class SignupTest < ActiveSupport::TestCase - test "#create_identity" do - signup = Signup.new(email_address: "brian@example.com") +class Fizzy::Saas::SignupTest < ActiveSupport::TestCase + test "#complete creates queenbee account and uses its id as tenant" do + queenbee_account = mock("queenbee_account") + queenbee_account.stubs(:id).returns(123456) - assert_difference -> { ::Identity.count }, 1 do - assert_difference -> { ::MagicLink.count }, 1 do - assert signup.create_identity - end - end + Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account) + Account.any_instance.expects(:setup_customer_template).once - assert_empty signup.errors - assert signup.identity - assert signup.identity.persisted? - - signup_existing = Signup.new(email_address: "brian@example.com") - - assert_no_difference -> { ::Identity.count } do - assert_difference -> { ::MagicLink.count }, 1 do - assert signup_existing.create_identity, "Should send magic link for existing identity" - end - end - - signup_invalid = Signup.new(email_address: "") - assert_raises do - signup_invalid.create_identity - end - end - - test "#complete" do - ::Account.any_instance.expects(:setup_customer_template).once - - ::Current.without_account do + Current.without_account do signup = Signup.new( full_name: "Kevin", identity: identities(:kevin) @@ -40,15 +17,26 @@ class SignupTest < ActiveSupport::TestCase assert signup.complete assert signup.account - assert signup.user - assert_equal "Kevin", signup.user.name + assert_equal "123456", signup.account.external_account_id.to_s + end + end - signup_invalid = Signup.new( - full_name: "", + test "#complete calls cancel on queenbee account when account creation fails" do + queenbee_account = mock("queenbee_account") + queenbee_account.stubs(:id).returns(789012) + queenbee_account.expects(:cancel).once + + Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account) + Account.any_instance.stubs(:setup_customer_template).raises(StandardError.new("Account setup failed")) + + Current.without_account do + signup = Signup.new( + full_name: "Kevin", identity: identities(:kevin) ) - assert_not signup_invalid.complete - assert_not_empty signup_invalid.errors[:full_name] + + assert_not signup.complete + assert_includes signup.errors[:base], "Something went wrong, and we couldn't create your account. Please give it another try." end end end From 0b23a4f9074c3ba6b2bd3085694eceecd3fa7c73 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 13:12:58 +0100 Subject: [PATCH 046/318] Remove code not used now --- saas/app/models/concerns/.keep | 0 saas/app/models/signup.rb | 127 ------------------ .../models/signup/account_name_generator.rb | 53 -------- 3 files changed, 180 deletions(-) delete mode 100644 saas/app/models/concerns/.keep delete mode 100644 saas/app/models/signup.rb delete mode 100644 saas/app/models/signup/account_name_generator.rb diff --git a/saas/app/models/concerns/.keep b/saas/app/models/concerns/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/saas/app/models/signup.rb b/saas/app/models/signup.rb deleted file mode 100644 index ca76f5b08..000000000 --- a/saas/app/models/signup.rb +++ /dev/null @@ -1,127 +0,0 @@ -class Signup - include ActiveModel::Model - include ActiveModel::Attributes - include ActiveModel::Validations - - attr_accessor :full_name, :email_address, :identity - attr_reader :queenbee_account, :account, :user - - with_options on: :completion do - validates_presence_of :full_name, :identity - end - - def initialize(...) - @full_name = nil - @email_address = nil - @account = nil - @user = nil - @queenbee_account = nil - @identity = nil - - super - - @email_address = @identity.email_address if @identity - end - - def create_identity - @identity = Identity.find_or_create_by!(email_address: email_address) - @identity.send_magic_link - end - - def complete - if valid?(:completion) - begin - create_queenbee_account - create_account - - true - rescue => error - destroy_account - destroy_queenbee_account - - errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.") - Rails.error.report(error, severity: :error) - Rails.logger.error error - Rails.logger.error error.backtrace.join("\n") - - false - end - else - false - end - end - - private - def create_queenbee_account - @account_name = AccountNameGenerator.new(identity: identity, name: full_name).generate - @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) - @tenant = queenbee_account.id.to_s - end - - def destroy_queenbee_account - @queenbee_account&.cancel - @queenbee_account = nil - end - - def create_account - @account = Account.create_with_admin_user( - account: { - external_account_id: @tenant, - name: @account_name - }, - owner: { - name: full_name, - identity: identity - } - ) - @user = @account.users.find_by!(role: :admin) - @account.setup_customer_template - end - - def destroy_account - @account&.destroy! - - @user = nil - @account = nil - @tenant = nil - end - - def queenbee_account_attributes - {}.tap do |attributes| - attributes[:product_name] = "fizzy" - attributes[:name] = @account_name - attributes[:owner_name] = full_name - attributes[:owner_email] = email_address - - attributes[:trial] = true - attributes[:subscription] = subscription_attributes - attributes[:remote_request] = request_attributes - - # # TODO: Terms of Service - # attributes[:terms_of_service] = true - - # We've confirmed the email - attributes[:auto_allow] = true - - # Tell Queenbee to skip the request to create a local account. We've created it ourselves. - attributes[:skip_remote] = true - end - end - - def subscription_attributes - subscription = FreeV1Subscription - - {}.tap do |attributes| - attributes[:name] = subscription.to_param - attributes[:price] = subscription.price - end - end - - def request_attributes - {}.tap do |attributes| - attributes[:remote_address] = Current.ip_address - attributes[:user_agent] = Current.user_agent - attributes[:referrer] = Current.referrer - end - end -end diff --git a/saas/app/models/signup/account_name_generator.rb b/saas/app/models/signup/account_name_generator.rb deleted file mode 100644 index a6844d3a3..000000000 --- a/saas/app/models/signup/account_name_generator.rb +++ /dev/null @@ -1,53 +0,0 @@ -class Signup::AccountNameGenerator - SUFFIX = "Fizzy".freeze - - attr_reader :identity, :name - - def initialize(identity:, name:) - @identity = identity - @name = name - end - - def generate - next_index = current_index + 1 - - if next_index == 1 - "#{prefix} #{SUFFIX}" - else - "#{prefix} #{next_index.ordinalize} #{SUFFIX}" - end - end - - private - def current_index - existing_indices.max || 0 - end - - def existing_indices - Current.without_account do - identity.accounts.filter_map do |account| - if account.name.match?(first_account_name_regex) - 1 - elsif match = account.name.match(nth_account_name_regex) - match[1].to_i - end - end - end - end - - def first_account_name_regex - @first_account_name_regex ||= /\A#{prefix}\s+#{SUFFIX}\Z/i - end - - def nth_account_name_regex - @nth_account_name_regex ||= /\A#{prefix}\s+(1st|2nd|3rd|\d+th)\s+#{SUFFIX}/i - end - - def prefix - @prefix ||= "#{first_name}'s" - end - - def first_name - name.strip.split(" ", 2).first - end -end From 1217f569ad390798bf308484ba98b0b43b162660 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 13:20:25 +0100 Subject: [PATCH 047/318] This is a number, no need to convert --- saas/test/models/signup_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb index 3754906af..b0c348453 100644 --- a/saas/test/models/signup_test.rb +++ b/saas/test/models/signup_test.rb @@ -17,7 +17,7 @@ class Fizzy::Saas::SignupTest < ActiveSupport::TestCase assert signup.complete assert signup.account - assert_equal "123456", signup.account.external_account_id.to_s + assert_equal 123456, signup.account.external_account_id end end From b5ca92b999ee40f779b48f0238a5078b919aa520 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 13:21:05 +0100 Subject: [PATCH 048/318] Superfluous --- saas/lib/fizzy/saas/signup.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/saas/lib/fizzy/saas/signup.rb b/saas/lib/fizzy/saas/signup.rb index e414d63f8..fdf46cb1b 100644 --- a/saas/lib/fizzy/saas/signup.rb +++ b/saas/lib/fizzy/saas/signup.rb @@ -15,7 +15,6 @@ module Fizzy def handle_account_creation_error(error) @queenbee_account&.cancel - @queenbee_account = nil end def queenbee_account_attributes From 7298d2534ac910361b8f5110bdec8455c789f889 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 13:24:43 +0100 Subject: [PATCH 049/318] Improve test --- saas/test/models/signup_test.rb | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb index b0c348453..b06edf44c 100644 --- a/saas/test/models/signup_test.rb +++ b/saas/test/models/signup_test.rb @@ -9,15 +9,20 @@ class Fizzy::Saas::SignupTest < ActiveSupport::TestCase Account.any_instance.expects(:setup_customer_template).once Current.without_account do - signup = Signup.new( - full_name: "Kevin", - identity: identities(:kevin) - ) + assert_changes -> { Account.count }, +1 do + sequence_value_before = Account::ExternalIdSequence.value - assert signup.complete + signup = Signup.new( + full_name: "Kevin", + identity: identities(:kevin) + ) - assert signup.account - assert_equal 123456, signup.account.external_account_id + assert signup.complete + + assert signup.account + assert_equal 123456, signup.account.external_account_id + assert_equal sequence_value_before, Account::ExternalIdSequence.value + end end end From 1af178b1522fd9c2ee4f58b1ad2c25025f18fdfc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 16:09:29 +0100 Subject: [PATCH 050/318] Only if replica configured --- saas/lib/fizzy/saas/engine.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index c0941f84e..e198f0911 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -15,7 +15,9 @@ module Fizzy end initializer "fizzy_saas.transaction_pinning" do |app| - app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) + if ActiveRecord::Base.replica_configured? + app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) + end end initializer "fizzy_saas.production_config", before: :load_config_initializers do |app| From 8bb27c3e31cc3a1617a28ee1f688c4518281980b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 27 Nov 2025 16:10:17 +0100 Subject: [PATCH 051/318] Should not be needed --- saas/lib/fizzy/saas/engine.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index e198f0911..c0941f84e 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -15,9 +15,7 @@ module Fizzy end initializer "fizzy_saas.transaction_pinning" do |app| - if ActiveRecord::Base.replica_configured? - app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) - end + app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end initializer "fizzy_saas.production_config", before: :load_config_initializers do |app| From 71a9c56a2c92b92b6d7fabc73f049e0a450e67a0 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 10:25:33 +0100 Subject: [PATCH 052/318] Add note about saas mode --- saas/README.md | 55 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/saas/README.md b/saas/README.md index ecaa3ede6..85a9fc952 100644 --- a/saas/README.md +++ b/saas/README.md @@ -1,28 +1,47 @@ -# Fizzy::Saas -Short description and motivation. +This is a Rails engine that [37signals](https://37signals.com/) bundles with [Fizzy](https://github.com/basecamp/fizzy) to offer the SaaS service at https://fizzy.do. -## Usage -How to use my plugin. +## Working locally in SaaS mode -## Installation -Add this line to your application's Gemfile: +To make Fizzy run in SaaS mode, run this in the terminal: ```ruby -gem "fizzy-saas" +bin/rails saas:enable ``` -And then execute: -```bash -$ bundle +To can go back to open source mode: + +```ruby +bin/rails saas:disable ``` -Or install it yourself as: -```bash -$ gem install fizzy-saas -``` +Then you can work do [Fizzy development as usual](https://github.com/basecamp/fizzy). -## Contributing -Contribution directions go here. +## Environments -## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +Fizzy is deployed with Kamal. You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. + +### Beta + +Beta is primarily intended for testing product features. + +Beta tenant is: + +- https://fizzy-beta.37signals.com + +This environment uses local disk for Active Storage. + + +### Staging + +Staging is primarily intended for testing infrastructure changes. + +- https://fizzy.37signals-staging.com/ + +This environment uses a FlashBlade bucket for blob storage, and shares nothing with Production. We may periodically copy data here from production. + + +### Production + +- https://app.fizzy.do/ + +This environment uses a FlashBlade bucket for blob storage. From 792c68877928a547750954d31e06a70f8c2c8193 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 10:31:08 +0100 Subject: [PATCH 053/318] Review README --- saas/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/README.md b/saas/README.md index 85a9fc952..119e7575c 100644 --- a/saas/README.md +++ b/saas/README.md @@ -1,6 +1,6 @@ This is a Rails engine that [37signals](https://37signals.com/) bundles with [Fizzy](https://github.com/basecamp/fizzy) to offer the SaaS service at https://fizzy.do. -## Working locally in SaaS mode +## Development To make Fizzy run in SaaS mode, run this in the terminal: From e4fa1d3431896ab0b62cfe98b29a6656eaf748f9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 10:31:45 +0100 Subject: [PATCH 054/318] Review README --- saas/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/README.md b/saas/README.md index 119e7575c..bdc4e5315 100644 --- a/saas/README.md +++ b/saas/README.md @@ -18,7 +18,7 @@ Then you can work do [Fizzy development as usual](https://github.com/basecamp/fi ## Environments -Fizzy is deployed with Kamal. You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. +Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. ### Beta From b315bf38e4798642f4de20eb5e24f778b79c234c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 10:59:35 +0100 Subject: [PATCH 055/318] Add link to the handbook --- saas/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/saas/README.md b/saas/README.md index bdc4e5315..a4c257e86 100644 --- a/saas/README.md +++ b/saas/README.md @@ -20,6 +20,10 @@ Then you can work do [Fizzy development as usual](https://github.com/basecamp/fi Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. +## Handbook + +See the [Fizzy handbook](https://handbooks.37signals.works/18/fizzy) for runbooks and other information. + ### Beta Beta is primarily intended for testing product features. From 95c8a763918bd36ed6eb3cc5d8a5b8a6cdc568dc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 10:59:50 +0100 Subject: [PATCH 056/318] Review --- saas/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/README.md b/saas/README.md index a4c257e86..4b52e4094 100644 --- a/saas/README.md +++ b/saas/README.md @@ -22,7 +22,7 @@ Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have t ## Handbook -See the [Fizzy handbook](https://handbooks.37signals.works/18/fizzy) for runbooks and other information. +See the [Fizzy handbook](https://handbooks.37signals.works/18/fizzy) for runbooks and more. ### Beta From d30e327f1e977511d15c92eca6df42e0e7e6e81a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 11:51:24 +0100 Subject: [PATCH 057/318] Move yabeda/prometheus configuration to gem --- saas/fizzy-saas.gemspec | 11 +++++++++++ saas/lib/fizzy/saas/engine.rb | 36 ++++++++++++++++++++++++++++++++++ saas/lib/yabeda/solid_queue.rb | 27 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 saas/lib/yabeda/solid_queue.rb diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 890b6fec2..0263f70e8 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -24,4 +24,15 @@ Gem::Specification.new do |spec| spec.add_dependency "rails", ">= 8.1.0.beta1" spec.add_dependency "queenbee" spec.add_dependency "rails_structured_logging" + spec.add_dependency "sentry-ruby" + spec.add_dependency "sentry-rails" + spec.add_dependency "yabeda" + spec.add_dependency "yabeda-actioncable" + spec.add_dependency "yabeda-activejob" + spec.add_dependency "yabeda-gc" + spec.add_dependency "yabeda-http_requests" + spec.add_dependency "yabeda-prometheus-mmap" + spec.add_dependency "yabeda-puma-plugin" + spec.add_dependency "yabeda-rails" + spec.add_dependency "prometheus-client-mmap" end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index c0941f84e..ce8a22062 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -18,6 +18,42 @@ module Fizzy app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end + initializer "fizzy_saas.sentry" do + if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank? + Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.breadcrumbs_logger = %i[ active_support_logger http_logger ] + config.send_default_pii = false + config.release = ENV["GIT_REVISION"] + config.excluded_exceptions += [ "ActiveRecord::ConcurrentMigrationError" ] + end + end + end + + initializer "fizzy_saas.yabeda" do + require "prometheus/client/support/puma" + + Prometheus::Client.configuration.logger = Rails.logger + Prometheus::Client.configuration.pid_provider = Prometheus::Client::Support::Puma.method(:worker_pid_provider) + Yabeda::Rails.config.controller_name_case = :camel + + Yabeda::ActiveJob.install! + + require "yabeda/solid_queue" + Yabeda::SolidQueue.install! + + Yabeda::ActionCable.configure do |config| + config.channel_class_name = "ActionCable::Channel::Base" + end + end + + initializer "fizzy_saas.solid_queue" do + SolidQueue.on_start do + Process.warmup + Yabeda::Prometheus::Exporter.start_metrics_server! + end + end + initializer "fizzy_saas.production_config", before: :load_config_initializers do |app| if Rails.env.local? if Rails.root.join("tmp/structured-logging.txt").exist? diff --git a/saas/lib/yabeda/solid_queue.rb b/saas/lib/yabeda/solid_queue.rb new file mode 100644 index 000000000..082ac8936 --- /dev/null +++ b/saas/lib/yabeda/solid_queue.rb @@ -0,0 +1,27 @@ +module Yabeda + module SolidQueue + def self.install! + Yabeda.configure do + group :solid_queue + + gauge :jobs_failed_count, comment: "Number of failed jobs" + gauge :jobs_unreleased_count, comment: "Number of claimed jobs that don't belong to any process" + gauge :jobs_scheduled_and_delayed_count, comment: "Number of scheduled jobs that have over 5 minutes delay" + gauge :recurring_tasks_count, comment: "Number of recurring jobs scheduled" + gauge :recurring_tasks_delayed_count, comment: "Number of recurring jobs that haven't been enqueued within their schedule" + + collect do + if ::SolidQueue.supervisor? + solid_queue.jobs_failed_count.set({}, ::SolidQueue::FailedExecution.count) + solid_queue.jobs_unreleased_count.set({}, ::SolidQueue::ClaimedExecution.where(process: nil).count) + solid_queue.jobs_scheduled_and_delayed_count.set({}, ::SolidQueue::ScheduledExecution.where(scheduled_at: ..5.minutes.ago).count) + solid_queue.recurring_tasks_count.set({}, ::SolidQueue::RecurringTask.count) + solid_queue.recurring_tasks_delayed_count.set({}, ::SolidQueue::RecurringTask.count do |task| + task.last_enqueued_time.present? && (task.previous_time - task.last_enqueued_time) > 5.minutes + end) + end + end + end + end + end +end From 61c53054da21c609462f819ce18ccc25f62b61df Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 11:54:33 +0100 Subject: [PATCH 058/318] Restructure a bit --- saas/lib/fizzy/saas/engine.rb | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index ce8a22062..2f29d11ab 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -8,12 +8,24 @@ module Fizzy # moved from config/initializers/queenbee.rb Queenbee.host_app = Fizzy + initializer "fizzy_saas.settings", before: :load_config_initializers do |app| + if Rails.env.local? + if Rails.root.join("tmp/structured-logging.txt").exist? + app.config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") + end + else + app.config.active_storage.service = :purestorage + app.config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) + end + end + initializer "fizzy.saas.mount" do |app| app.routes.append do mount Fizzy::Saas::Engine => "/", as: "saas" end end + initializer "fizzy_saas.transaction_pinning" do |app| app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end @@ -54,17 +66,6 @@ module Fizzy end end - initializer "fizzy_saas.production_config", before: :load_config_initializers do |app| - if Rails.env.local? - if Rails.root.join("tmp/structured-logging.txt").exist? - app.config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") - end - else - app.config.active_storage.service = :purestorage - app.config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) - end - end - # Load test mocks automatically in test environment initializer "fizzy_saas.test_mocks", after: :load_config_initializers do if Rails.env.test? From 82c4e87bf048162fa1ea8e891623d06614360011 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 11:59:20 +0100 Subject: [PATCH 059/318] Require metrics once Yabeda has been loaded --- saas/lib/fizzy/saas/engine.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 2f29d11ab..c5be0fce8 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -1,4 +1,3 @@ -require_relative "metrics" require_relative "transaction_pinning" require_relative "signup" @@ -57,6 +56,8 @@ module Fizzy Yabeda::ActionCable.configure do |config| config.channel_class_name = "ActionCable::Channel::Base" end + + require_relative "metrics" end initializer "fizzy_saas.solid_queue" do From 76fb864e40d22e1ddc2fbc745f5ad2ccd79be0b2 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 12:56:51 +0100 Subject: [PATCH 060/318] Review copy --- saas/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/README.md b/saas/README.md index 4b52e4094..16788f47c 100644 --- a/saas/README.md +++ b/saas/README.md @@ -1,4 +1,4 @@ -This is a Rails engine that [37signals](https://37signals.com/) bundles with [Fizzy](https://github.com/basecamp/fizzy) to offer the SaaS service at https://fizzy.do. +This is a Rails engine that [37signals](https://37signals.com/) bundles with [Fizzy](https://github.com/basecamp/fizzy) to offer the hosted version at https://fizzy.do. ## Development From e8e4a6cf69df4d8b30845f1cc5bb44c0a9456502 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 13:05:42 +0100 Subject: [PATCH 061/318] Move AGENTS.md here --- saas/AGENTS.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 saas/AGENTS.md diff --git a/saas/AGENTS.md b/saas/AGENTS.md new file mode 100644 index 000000000..35500f820 --- /dev/null +++ b/saas/AGENTS.md @@ -0,0 +1,60 @@ +## Production Observability + +Grafana MCP tools provide access to production metrics and logs for performance analysis. + +### Datasources +| Name | UID | Use | +|------|-----|-----| +| Thanos (Prometheus) | `PC96415006F908B67` | Metrics, latencies | +| Loki | `e38bdfea-097e-47fa-a7ab-774fd2487741` | Application logs | + +### Key Metrics +- `rails_request_duration_seconds_bucket:rate1m:sum_by_app:quantiles{app="fizzy"}` - Request latency percentiles +- `rails_request_total:rate1m:sum_by_controller_action{app="fizzy"}` - Request rates by endpoint +- `fizzy_replica_wait_seconds` - Database replica consistency wait times + +### Loki Log Labels and Query Patterns + +**Base label selector:** +```logql +{service_namespace="fizzy", deployment_environment_name="production", service_name="rails"} +``` + +**Useful JSON fields:** `event_duration_ms`, `performance_time_db_ms`, `performance_time_cpu_ms`, `rails_endpoint`, `rails_controller`, `url_path`, `authentication_identity_id`, `http_response_status_code` + +**Query patterns:** +- Filter by fields: `{labels} | field_name = "value"` +- Multiple field filters: `{labels} | field1 = "value1" | field2 = "value2"` +- Reduce returned labels: `{labels} | filters | keep field1,field2,field3` (reduces label payload) +- Minimize log line content: `{labels} | filters | line_format "{{.field_name}}"` (replaces raw log line) +- Combine both for minimal tokens: `{labels} | filters | keep field1,field2 | line_format "{{.field1}}"` +- **Important:** Fields are pre-parsed by the OTel collector. Don't use string search (`|=`) when filtering structured fields +- **Important:** Do NOT use `| json` - it will cause JSONParserErr since fields are already parsed as labels + +**Token management (CRITICAL):** +- Always probe with `limit: 3` first to check response size before running larger queries +- Aggregations return time series (many data points), not single values - can explode token usage +- NEVER use `sum by (field)` - returns a time series per unique value, easily exceeds token limits +- For breakdowns by field: fetch raw logs with `| keep field | line_format "{{.field}}"` and count client-side + +**Aggregations for statistics (use instead of fetching raw logs):** +- `mcp__grafana__query_loki_logs` returns limited results (default 10, max ~100) and large responses get truncated; use aggregations for statistics on large datasets +- Count: `sum(count_over_time({labels} | filters [12h]))` +- Percentiles: `quantile_over_time(0.95, {labels} | filters | unwrap field_name | __error__="" [12h]) by ()` +- Average: `avg_over_time({labels} | filters | unwrap field_name | __error__="" [12h]) by ()` +- Min/Max: `min_over_time(...)` / `max_over_time(...)` +- The `| unwrap field_name | __error__=""` pattern extracts numeric values from pre-parsed labels +- Use `by ()` or wrap in `sum()` to avoid cardinality limits + +**Documentation:** For advanced LogQL syntax (aggregations, pattern matching, etc.), consult https://grafana.com/docs/loki/latest/query/ + +### Instrumentation +Yabeda-based metrics exported at `:9394/metrics`. Config in `config/initializers/yabeda.rb`. + +### Sentry Error Tracking +Organization: `basecamp` | Project: `fizzy` | Region: `https://us.sentry.io` + +Use Sentry MCP tools to investigate production errors: +- `search_issues` - Find grouped issues by natural language query +- `get_issue_details` - Get full stacktrace and context for a specific issue +- `analyze_issue_with_seer` - AI-powered root cause analysis with code fix suggestions From 305e416cc00ccf52daf6a73475beaada84940fe8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 13:06:43 +0100 Subject: [PATCH 062/318] Include AGENTS.md with the gem --- saas/fizzy-saas.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 0263f70e8..beb780fe4 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |spec| spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib,test}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] + Dir["{app,config,db,lib,test}/**/*", "AGENTS.md", "MIT-LICENSE", "Rakefile", "README.md"] end spec.add_dependency "rails", ">= 8.1.0.beta1" From 45b8db4447c5d350232779be0ef3a6d6b3bda6b3 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 13:11:05 +0100 Subject: [PATCH 063/318] We will move this to shipyard --- saas/AGENTS.md | 60 -------------------------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 saas/AGENTS.md diff --git a/saas/AGENTS.md b/saas/AGENTS.md deleted file mode 100644 index 35500f820..000000000 --- a/saas/AGENTS.md +++ /dev/null @@ -1,60 +0,0 @@ -## Production Observability - -Grafana MCP tools provide access to production metrics and logs for performance analysis. - -### Datasources -| Name | UID | Use | -|------|-----|-----| -| Thanos (Prometheus) | `PC96415006F908B67` | Metrics, latencies | -| Loki | `e38bdfea-097e-47fa-a7ab-774fd2487741` | Application logs | - -### Key Metrics -- `rails_request_duration_seconds_bucket:rate1m:sum_by_app:quantiles{app="fizzy"}` - Request latency percentiles -- `rails_request_total:rate1m:sum_by_controller_action{app="fizzy"}` - Request rates by endpoint -- `fizzy_replica_wait_seconds` - Database replica consistency wait times - -### Loki Log Labels and Query Patterns - -**Base label selector:** -```logql -{service_namespace="fizzy", deployment_environment_name="production", service_name="rails"} -``` - -**Useful JSON fields:** `event_duration_ms`, `performance_time_db_ms`, `performance_time_cpu_ms`, `rails_endpoint`, `rails_controller`, `url_path`, `authentication_identity_id`, `http_response_status_code` - -**Query patterns:** -- Filter by fields: `{labels} | field_name = "value"` -- Multiple field filters: `{labels} | field1 = "value1" | field2 = "value2"` -- Reduce returned labels: `{labels} | filters | keep field1,field2,field3` (reduces label payload) -- Minimize log line content: `{labels} | filters | line_format "{{.field_name}}"` (replaces raw log line) -- Combine both for minimal tokens: `{labels} | filters | keep field1,field2 | line_format "{{.field1}}"` -- **Important:** Fields are pre-parsed by the OTel collector. Don't use string search (`|=`) when filtering structured fields -- **Important:** Do NOT use `| json` - it will cause JSONParserErr since fields are already parsed as labels - -**Token management (CRITICAL):** -- Always probe with `limit: 3` first to check response size before running larger queries -- Aggregations return time series (many data points), not single values - can explode token usage -- NEVER use `sum by (field)` - returns a time series per unique value, easily exceeds token limits -- For breakdowns by field: fetch raw logs with `| keep field | line_format "{{.field}}"` and count client-side - -**Aggregations for statistics (use instead of fetching raw logs):** -- `mcp__grafana__query_loki_logs` returns limited results (default 10, max ~100) and large responses get truncated; use aggregations for statistics on large datasets -- Count: `sum(count_over_time({labels} | filters [12h]))` -- Percentiles: `quantile_over_time(0.95, {labels} | filters | unwrap field_name | __error__="" [12h]) by ()` -- Average: `avg_over_time({labels} | filters | unwrap field_name | __error__="" [12h]) by ()` -- Min/Max: `min_over_time(...)` / `max_over_time(...)` -- The `| unwrap field_name | __error__=""` pattern extracts numeric values from pre-parsed labels -- Use `by ()` or wrap in `sum()` to avoid cardinality limits - -**Documentation:** For advanced LogQL syntax (aggregations, pattern matching, etc.), consult https://grafana.com/docs/loki/latest/query/ - -### Instrumentation -Yabeda-based metrics exported at `:9394/metrics`. Config in `config/initializers/yabeda.rb`. - -### Sentry Error Tracking -Organization: `basecamp` | Project: `fizzy` | Region: `https://us.sentry.io` - -Use Sentry MCP tools to investigate production errors: -- `search_issues` - Find grouped issues by natural language query -- `get_issue_details` - Get full stacktrace and context for a specific issue -- `analyze_issue_with_seer` - AI-powered root cause analysis with code fix suggestions From 054c8ebbbda1aaa279c49a2b696ac1f43b248bc2 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 13:11:37 +0100 Subject: [PATCH 064/318] Remove AGENTS.md --- saas/fizzy-saas.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index beb780fe4..0263f70e8 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |spec| spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib,test}/**/*", "AGENTS.md", "MIT-LICENSE", "Rakefile", "README.md"] + Dir["{app,config,db,lib,test}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] end spec.add_dependency "rails", ">= 8.1.0.beta1" From b6bb2338ed84be4a2a0e534a56bd2c0d269bd2e6 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 14:39:23 +0100 Subject: [PATCH 065/318] Align beta deploy descriptor --- saas/config/deploy.beta.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 050bce25b..28a225bed 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -20,11 +20,11 @@ ssh: env: clear: RAILS_ENV: beta - MYSQL_DATABASE_HOST: fizzy-beta-mysql-primary - MYSQL_DATABASE_REPLICA_HOST: fizzy-beta-mysql-replica - MYSQL_SOLID_CABLE_HOST: fizzy-beta-mysql-primary - MYSQL_SOLID_QUEUE_HOST: fizzy-beta-mysql-primary - MYSQL_SOLID_CACHE_HOST: fizzy-beta-mysql-primary + MYSQL_DATABASE_HOST: fizzy-mysql-primary + MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica + MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary + MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary + MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101 secret: - RAILS_MASTER_KEY - MYSQL_ALTER_PASSWORD @@ -46,6 +46,10 @@ accessories: load-balancer: image: basecamp/kamal-proxy:lb host: fizzy-beta-lb-01.sc-chi-int.37signals.com + labels: + otel_role: load-balancer + otel_service: fizzy-load-balancer + otel_scrape_enabled: true options: publish: - 80:80 From 92b00b744ff20689367579b0285f5eb54a3e0f8d Mon Sep 17 00:00:00 2001 From: Lewis Buckley Date: Fri, 28 Nov 2025 13:49:19 +0000 Subject: [PATCH 066/318] Drop healthcheck metrics --- saas/fizzy-saas.gemspec | 2 +- saas/lib/fizzy/saas/engine.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 0263f70e8..666d41940 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -33,6 +33,6 @@ Gem::Specification.new do |spec| spec.add_dependency "yabeda-http_requests" spec.add_dependency "yabeda-prometheus-mmap" spec.add_dependency "yabeda-puma-plugin" - spec.add_dependency "yabeda-rails" + spec.add_dependency "yabeda-rails", ">= 0.10" spec.add_dependency "prometheus-client-mmap" end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index c5be0fce8..24d7d3baf 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -47,6 +47,9 @@ module Fizzy Prometheus::Client.configuration.logger = Rails.logger Prometheus::Client.configuration.pid_provider = Prometheus::Client::Support::Puma.method(:worker_pid_provider) Yabeda::Rails.config.controller_name_case = :camel + Yabeda::Rails.config.ignore_actions = %w[ + Rails::HealthController#show + ] Yabeda::ActiveJob.install! From de1e344b89a0bfc84d3f9178e41838d94f293457 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 15:05:49 +0100 Subject: [PATCH 067/318] Align beta deploy config with main's --- saas/config/deploy.beta.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 28a225bed..6d6cfea0d 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -1,13 +1,13 @@ servers: web: hosts: - - fizzy-beta-app-01.sc-chi-int.37signals.com - - fizzy-beta-app-101.df-iad-int.37signals.com + - fizzy-beta-app-01.sc-chi-int.37signals.com: sc_chi + - fizzy-beta-app-101.df-iad-int.37signals.com: df_iad labels: otel_scrape_enabled: true jobs: hosts: - - fizzy-beta-app-01.sc-chi-int.37signals.com + - fizzy-beta-app-01.sc-chi-int.37signals.com: sc_chi labels: otel_scrape_enabled: true @@ -41,6 +41,10 @@ env: - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET - SENTRY_DSN + tags: + sc_chi: {} + df_iad: + PRIMARY_DATACENTER: true accessories: load-balancer: From 2f9a34117f65f54fed9a61d503731ff5ddd7d069 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 15:32:02 +0100 Subject: [PATCH 068/318] Bring changes from https://github.com/basecamp/fizzy/commit/91929d7c092f15fa6f145a17e61aa6493d3edd34 --- saas/config/deploy.production.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index 48f49f524..e7da34310 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -60,8 +60,11 @@ accessories: image: basecamp/kamal-proxy:lb hosts: - fizzy-lb-101.df-iad-int.37signals.com + - fizzy-lb-102.df-iad-int.37signals.com - fizzy-lb-01.sc-chi-int.37signals.com + - fizzy-lb-02.sc-chi-int.37signals.com - fizzy-lb-401.df-ams-int.37signals.com + - fizzy-lb-402.df-ams-int.37signals.com labels: otel_role: load-balancer otel_service: fizzy-load-balancer From ddbbf68d8ccaf6eb87feb3261e406277a1a89ac7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 15:34:56 +0100 Subject: [PATCH 069/318] Move scripts from the main app --- saas/script/configure-lb-beta.sh | 15 ++++ saas/script/configure-lb-production.sh | 94 ++++++++++++++++++++++++++ saas/script/configure-lb-staging.sh | 47 +++++++++++++ 3 files changed, 156 insertions(+) create mode 100755 saas/script/configure-lb-beta.sh create mode 100755 saas/script/configure-lb-production.sh create mode 100755 saas/script/configure-lb-staging.sh diff --git a/saas/script/configure-lb-beta.sh b/saas/script/configure-lb-beta.sh new file mode 100755 index 000000000..73844ee2b --- /dev/null +++ b/saas/script/configure-lb-beta.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +# fizzy-beta-lb-01.sc-chi-int.37signals.com +# +ssh app@fizzy-beta-lb-01.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=fizzy-beta.37signals.com \ + --writer-affinity-timeout=0 \ + --target=fizzy-beta-app-101.df-iad-int.37signals.com \ + --read-target=fizzy-beta-app-01.sc-chi-int.37signals.com diff --git a/saas/script/configure-lb-production.sh b/saas/script/configure-lb-production.sh new file mode 100755 index 000000000..a3e914d3f --- /dev/null +++ b/saas/script/configure-lb-production.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -e + +# fizzy-lb-101.df-iad-int.37signals.com +# +ssh app@fizzy-lb-101.df-iad-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com + + +# fizzy-lb-102.df-iad-int.37signals.com +# +ssh app@fizzy-lb-102.df-iad-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com + + +# fizzy-lb-01.sc-chi-int.37signals.com +# +ssh app@fizzy-lb-01.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-app-02.sc-chi-int.37signals.com + + +# fizzy-lb-02.sc-chi-int.37signals.com +# +ssh app@fizzy-lb-02.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-app-02.sc-chi-int.37signals.com + + +# fizzy-lb-401.df-ams-int.37signals.com +# +ssh app@fizzy-lb-401.df-ams-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-app-402.df-ams-int.37signals.com + + +# fizzy-lb-402.df-ams-int.37signals.com +# +ssh app@fizzy-lb-402.df-ams-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-app-402.df-ams-int.37signals.com diff --git a/saas/script/configure-lb-staging.sh b/saas/script/configure-lb-staging.sh new file mode 100755 index 000000000..3e88cbcb1 --- /dev/null +++ b/saas/script/configure-lb-staging.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -e + +# fizzy-staging-lb-01.sc-chi-int.37signals.com +# +ssh app@fizzy-staging-lb-01.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=fizzy.37signals-staging.com \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-staging-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-staging-app-02.sc-chi-int.37signals.com + +# fizzy-staging-lb-101.df-iad-int.37signals.com +# +ssh app@fizzy-staging-lb-101.df-iad-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=fizzy.37signals-staging.com \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com + +# fizzy-staging-lb-401.df-ams-int.37signals.com +# +ssh app@fizzy-staging-lb-401.df-ams-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=fizzy.37signals-staging.com \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-staging-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-staging-app-402.df-ams-int.37signals.com + From 230693f092effe45103ca4aa1edaf34fed4be385 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 28 Nov 2025 16:30:03 +0100 Subject: [PATCH 070/318] Add note about how to update Fizzy (we need to improve this and make it more seamless) --- saas/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/saas/README.md b/saas/README.md index 16788f47c..27da709f2 100644 --- a/saas/README.md +++ b/saas/README.md @@ -16,6 +16,14 @@ bin/rails saas:disable Then you can work do [Fizzy development as usual](https://github.com/basecamp/fizzy). +## How to update Fizzy + +After making changes to this gem, you need to update Fizzy to pick up the changes: + +```ruby +BUNDLE_GEMFILE=Gemfile.saas bundle update --conservative fizzy-saas +``` + ## Environments Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. From 0ce46ddc36f8f45bb3f25d4e33b7c238805c9021 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 29 Nov 2025 10:05:16 +0100 Subject: [PATCH 071/318] Remove unused route --- saas/config/routes.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 1f720e87d..cb130af4f 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -1,5 +1,3 @@ Fizzy::Saas::Engine.routes.draw do - get "/signup/new", to: redirect("/session/new") - Queenbee.routes(self) end From 43bf226444aef0d2706bf9f1b85c611d0107911b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 29 Nov 2025 11:16:01 +0100 Subject: [PATCH 072/318] Remove duplicate test --- saas/test/controllers/.keep | 0 .../controllers/sessions_controller_test.rb | 17 ----------------- 2 files changed, 17 deletions(-) create mode 100644 saas/test/controllers/.keep delete mode 100644 saas/test/controllers/sessions_controller_test.rb diff --git a/saas/test/controllers/.keep b/saas/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/controllers/sessions_controller_test.rb b/saas/test/controllers/sessions_controller_test.rb deleted file mode 100644 index 6af58e63e..000000000 --- a/saas/test/controllers/sessions_controller_test.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "test_helper" - -class Fizzy::Saas::SessionsControllerTest < ActionDispatch::IntegrationTest - test "create for a new user" do - untenanted do - assert_difference -> { Identity.count }, +1 do - assert_difference -> { MagicLink.count }, +1 do - post session_path, - params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } - end - end - - assert_redirected_to session_magic_link_path - end - end -end - From 299212aae1b533abf04e98ed8504db4f7da17a82 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 29 Nov 2025 11:46:23 +0100 Subject: [PATCH 073/318] Move env-specific bits to env files TIL Rails engines load these by default before loading the host app's --- saas/config/environments/beta.rb | 8 ++++ saas/config/environments/development.rb | 5 ++ saas/config/environments/production.rb | 8 ++++ saas/config/environments/staging.rb | 8 ++++ saas/config/environments/test.rb | 1 + saas/lib/fizzy/saas/engine.rb | 61 +++++++++---------------- 6 files changed, 51 insertions(+), 40 deletions(-) create mode 100644 saas/config/environments/beta.rb create mode 100644 saas/config/environments/development.rb create mode 100644 saas/config/environments/production.rb create mode 100644 saas/config/environments/staging.rb create mode 100644 saas/config/environments/test.rb diff --git a/saas/config/environments/beta.rb b/saas/config/environments/beta.rb new file mode 100644 index 000000000..11025f141 --- /dev/null +++ b/saas/config/environments/beta.rb @@ -0,0 +1,8 @@ +require_relative "production" + +Rails.application.configure do + config.action_mailer.smtp_settings[:domain] = "fizzy-beta.37signals.com" + config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging" + config.action_mailer.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" } + config.action_controller.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" } +end diff --git a/saas/config/environments/development.rb b/saas/config/environments/development.rb new file mode 100644 index 000000000..7b442793c --- /dev/null +++ b/saas/config/environments/development.rb @@ -0,0 +1,5 @@ +Rails.application.configure do + if Rails.root.join("tmp/structured-logging.txt").exist? + config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") + end +end diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb new file mode 100644 index 000000000..27708a025 --- /dev/null +++ b/saas/config/environments/production.rb @@ -0,0 +1,8 @@ +Rails.application.configure do + config.active_storage.service = :purestorage + config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) + + config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" } + config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" } + config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false } +end diff --git a/saas/config/environments/staging.rb b/saas/config/environments/staging.rb new file mode 100644 index 000000000..f23bc800b --- /dev/null +++ b/saas/config/environments/staging.rb @@ -0,0 +1,8 @@ +require_relative "production" + +Rails.application.configure do + config.action_mailer.smtp_settings[:domain] = "fizzy.37signals-staging.com" + config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging" + config.action_mailer.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" } + config.action_controller.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" } +end diff --git a/saas/config/environments/test.rb b/saas/config/environments/test.rb new file mode 100644 index 000000000..3c425a8df --- /dev/null +++ b/saas/config/environments/test.rb @@ -0,0 +1 @@ +require "fizzy/saas/testing" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index c5be0fce8..ff175daac 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -7,28 +7,37 @@ module Fizzy # moved from config/initializers/queenbee.rb Queenbee.host_app = Fizzy - initializer "fizzy_saas.settings", before: :load_config_initializers do |app| - if Rails.env.local? - if Rails.root.join("tmp/structured-logging.txt").exist? - app.config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") - end - else - app.config.active_storage.service = :purestorage - app.config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) - end - end - initializer "fizzy.saas.mount" do |app| app.routes.append do mount Fizzy::Saas::Engine => "/", as: "saas" end end - initializer "fizzy_saas.transaction_pinning" do |app| app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end + initializer "fizzy_saas.solid_queue" do + SolidQueue.on_start do + Process.warmup + Yabeda::Prometheus::Exporter.start_metrics_server! + end + end + + initializer "fizzy_saas.logging.session" do |app| + ActiveSupport.on_load(:action_controller_base) do + before_action do + if Current.identity.present? + logger.struct(" Authorized Identity##{Current.identity.id}", authentication: { identity: { id: Current.identity.id } }) + end + + if Current.account.present? + logger.struct(account: { queenbee_id: Current.account.external_account_id }) + end + end + end + end + initializer "fizzy_saas.sentry" do if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank? Sentry.init do |config| @@ -60,34 +69,6 @@ module Fizzy require_relative "metrics" end - initializer "fizzy_saas.solid_queue" do - SolidQueue.on_start do - Process.warmup - Yabeda::Prometheus::Exporter.start_metrics_server! - end - end - - # Load test mocks automatically in test environment - initializer "fizzy_saas.test_mocks", after: :load_config_initializers do - if Rails.env.test? - require "fizzy/saas/testing" - end - end - - initializer "fizzy_saas.logging.session" do |app| - ActiveSupport.on_load(:action_controller_base) do - before_action do - if Current.identity.present? - logger.struct(" Authorized Identity##{Current.identity.id}", authentication: { identity: { id: Current.identity.id } }) - end - - if Current.account.present? - logger.struct(account: { queenbee_id: Current.account.external_account_id }) - end - end - end - end - config.to_prepare do ::Signup.prepend(Fizzy::Saas::Signup) From 1a7473521840161e5e2110755316ddf40a728f9d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 29 Nov 2025 11:53:47 +0100 Subject: [PATCH 074/318] We need to require this *after* or the mocks won't work --- saas/config/environments/test.rb | 1 - saas/lib/fizzy/saas/engine.rb | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) delete mode 100644 saas/config/environments/test.rb diff --git a/saas/config/environments/test.rb b/saas/config/environments/test.rb deleted file mode 100644 index 3c425a8df..000000000 --- a/saas/config/environments/test.rb +++ /dev/null @@ -1 +0,0 @@ -require "fizzy/saas/testing" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index ff175daac..cec30840d 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -38,6 +38,13 @@ module Fizzy end end + # Load test mocks automatically in test environment + initializer "fizzy_saas.test_mocks", after: :load_config_initializers do + if Rails.env.test? + require "fizzy/saas/testing" + end + end + initializer "fizzy_saas.sentry" do if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank? Sentry.init do |config| From 5366b898925e6f1512a329045bedc357c05df24a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 29 Nov 2025 11:54:01 +0100 Subject: [PATCH 075/318] Test moved to the host app as part of making signup code identical --- .../controllers/sessions_controller_test.rb | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 saas/test/controllers/sessions_controller_test.rb diff --git a/saas/test/controllers/sessions_controller_test.rb b/saas/test/controllers/sessions_controller_test.rb deleted file mode 100644 index 6af58e63e..000000000 --- a/saas/test/controllers/sessions_controller_test.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "test_helper" - -class Fizzy::Saas::SessionsControllerTest < ActionDispatch::IntegrationTest - test "create for a new user" do - untenanted do - assert_difference -> { Identity.count }, +1 do - assert_difference -> { MagicLink.count }, +1 do - post session_path, - params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } - end - end - - assert_redirected_to session_magic_link_path - end - end -end - From 5a8b980f208adbad1f17b1f114ccb990693dedc4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 29 Nov 2025 16:26:49 +0100 Subject: [PATCH 076/318] Update README --- saas/README.md | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/saas/README.md b/saas/README.md index 27da709f2..9b204f0f4 100644 --- a/saas/README.md +++ b/saas/README.md @@ -32,28 +32,22 @@ Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have t See the [Fizzy handbook](https://handbooks.37signals.works/18/fizzy) for runbooks and more. -### Beta - -Beta is primarily intended for testing product features. - -Beta tenant is: - -- https://fizzy-beta.37signals.com - -This environment uses local disk for Active Storage. - - -### Staging - -Staging is primarily intended for testing infrastructure changes. - -- https://fizzy.37signals-staging.com/ - -This environment uses a FlashBlade bucket for blob storage, and shares nothing with Production. We may periodically copy data here from production. - - ### Production - https://app.fizzy.do/ This environment uses a FlashBlade bucket for blob storage. + +### Beta + +Beta is primarily intended for testing product features. It uses the same production database and Active Storage configuration. + +Beta tenant is: + +- https://fizzy-beta.37signals.com + +### Staging + +Staging is primarily intended for testing infrastructure changes. It uses production-like but separate database and Active Storage configurations. + +- https://fizzy.37signals-staging.com/ From f7482d5c553d97226ccf95bcdc5e54139fcd2365 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sat, 29 Nov 2025 21:45:48 +0100 Subject: [PATCH 077/318] Move solid-queue dev setup to the engine --- saas/config/environments/development.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/saas/config/environments/development.rb b/saas/config/environments/development.rb index 7b442793c..eb82f052b 100644 --- a/saas/config/environments/development.rb +++ b/saas/config/environments/development.rb @@ -2,4 +2,9 @@ Rails.application.configure do if Rails.root.join("tmp/structured-logging.txt").exist? config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") end + + if Rails.root.join("tmp/solid-queue.txt").exist? + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } } + end end From d2db893677f909fabd21deeb8db86a413acafa94 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 29 Nov 2025 19:22:08 -0500 Subject: [PATCH 078/318] We don't run jobs in beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I think this was hallucinated in the original commit. This is causing racing between beta and prod, and as a result beta was attempting to send emails for production. Fortunately, smtp is not configured in beta and prevented bad emails from going out. 😅 --- saas/config/deploy.beta.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 6d6cfea0d..bc08b11da 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -5,11 +5,9 @@ servers: - fizzy-beta-app-101.df-iad-int.37signals.com: df_iad labels: otel_scrape_enabled: true - jobs: - hosts: - - fizzy-beta-app-01.sc-chi-int.37signals.com: sc_chi - labels: - otel_scrape_enabled: true + +# we don't run the jobs role in beta +allow_empty_roles: true proxy: ssl: false From a204b188b05258d3d31ccd9439f1bd0dcd410daf Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 2 Dec 2025 15:27:15 +0100 Subject: [PATCH 079/318] Fix: log identity ids --- saas/lib/fizzy/saas/engine.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 409d4b202..06e2df911 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -28,7 +28,7 @@ module Fizzy ActiveSupport.on_load(:action_controller_base) do before_action do if Current.identity.present? - logger.struct(" Authorized Identity##{Current.identity.id}", authentication: { identity: { id: Current.identity.id } }) + logger.struct(authentication: { identity: { id: Current.identity.id } }) end if Current.account.present? From bf4573a7df55ea58d28045ddd8038344ab73f790 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 2 Dec 2025 11:57:59 -0500 Subject: [PATCH 080/318] Bump GC parameters per autotuner advice: The following suggestions reduce the number of minor garbage collection cycles, specifically a cycle called "malloc". Your app runs malloc cycles in approximately 25.00% of all minor garbage collection cycles. --- saas/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/Dockerfile b/saas/Dockerfile index 2b08addf5..902f6685f 100644 --- a/saas/Dockerfile +++ b/saas/Dockerfile @@ -69,8 +69,8 @@ ENV RUBY_GC_HEAP_0_INIT_SLOTS=692636 \ RUBY_GC_HEAP_2_INIT_SLOTS=148807 \ RUBY_GC_HEAP_3_INIT_SLOTS=9169 \ RUBY_GC_HEAP_4_INIT_SLOTS=3054 \ - RUBY_GC_MALLOC_LIMIT=33554432 \ - RUBY_GC_MALLOC_LIMIT_MAX=67108864 \ + RUBY_GC_MALLOC_LIMIT=67108864 \ + RUBY_GC_MALLOC_LIMIT_MAX=134217728 \ LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 # Start the server by default, this can be overwritten at runtime From 9f6f4da363ad127f6004617153dafb445e8b6d09 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 2 Dec 2025 13:33:27 -0800 Subject: [PATCH 081/318] Release under O'Saasy license (#16) --- saas/LICENSE.md | 10 ++++++++++ saas/README.md | 4 ++++ saas/fizzy-saas.gemspec | 14 +++++++------- 3 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 saas/LICENSE.md diff --git a/saas/LICENSE.md b/saas/LICENSE.md new file mode 100644 index 000000000..db5383855 --- /dev/null +++ b/saas/LICENSE.md @@ -0,0 +1,10 @@ +# O'Saasy License Agreement + +Copyright © 2025, 37signals LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/saas/README.md b/saas/README.md index 9b204f0f4..934715de4 100644 --- a/saas/README.md +++ b/saas/README.md @@ -51,3 +51,7 @@ Beta tenant is: Staging is primarily intended for testing infrastructure changes. It uses production-like but separate database and Active Storage configurations. - https://fizzy.37signals-staging.com/ + +## License + +fizzy-saas is released under the [O'Saasy License](LICENSE.md). diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 666d41940..6acc1bda2 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -5,20 +5,20 @@ Gem::Specification.new do |spec| spec.version = Fizzy::Saas::VERSION spec.authors = [ "Mike Dalessio" ] spec.email = [ "mike@37signals.com" ] - spec.homepage = "TODO" - spec.summary = "TODO: Summary of Fizzy::Saas." - spec.description = "TODO: Description of Fizzy::Saas." + spec.homepage = "https://github.com/basecamp/fizzy-saas" + spec.summary = "37signals SaaS companion for Fizzy" + spec.description = "Rails engine that bundles with Fizzy to offer the hosted version at https://app.fizzy.do" + spec.license = "O'Saasy" # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host" # to allow pushing to a single host or delete this section to allow pushing to any host. - spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + spec.metadata["source_code_uri"] = "https://github.com/basecamp/fizzy-saas" spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib,test}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] + Dir["{app,config,db,lib,test}/**/*", "LICENSE.md", "Rakefile", "README.md"] end spec.add_dependency "rails", ">= 8.1.0.beta1" From 74e968ba9d142a82c8be2c1b269049a747191e3b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 2 Dec 2025 13:34:20 -0800 Subject: [PATCH 082/318] Bundler: normalize platforms --- saas/Gemfile.lock | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock index 2adbd96d7..3b9a7d5c0 100644 --- a/saas/Gemfile.lock +++ b/saas/Gemfile.lock @@ -144,10 +144,22 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) + racc (~> 1.4) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -217,8 +229,15 @@ GEM zeitwerk (2.7.3) PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl arm64-darwin + x86_64-darwin x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES activeresource From 1bba8457e2f7b00ee70f1575ab52209a91743988 Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 2 Dec 2025 16:34:44 -0500 Subject: [PATCH 083/318] Fix typo in README for open source mode instructions (#13) --- saas/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/README.md b/saas/README.md index 934715de4..0e0387708 100644 --- a/saas/README.md +++ b/saas/README.md @@ -8,7 +8,7 @@ To make Fizzy run in SaaS mode, run this in the terminal: bin/rails saas:enable ``` -To can go back to open source mode: +To go back to open source mode: ```ruby bin/rails saas:disable From ca6caa7a92919324ff0342da5e06e498a86949a5 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 2 Dec 2025 21:54:13 -0500 Subject: [PATCH 084/318] Add deployment secrets for active record encryption which is used by console1984 and audits1984. ref: https://app.fizzy.do/5986089/cards/2469 --- saas/.kamal/secrets.beta | 5 ++++- saas/.kamal/secrets.production | 5 ++++- saas/.kamal/secrets.staging | 5 ++++- saas/Dockerfile | 8 ++++++-- saas/config/deploy.beta.yml | 3 +++ saas/config/deploy.production.yml | 3 +++ saas/config/deploy.staging.yml | 3 +++ saas/config/deploy.yml | 8 ++------ 8 files changed, 29 insertions(+), 11 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index ed17b6201..744e38da3 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -18,3 +18,6 @@ ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_A QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index acd4576fe..d94718f6c 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -18,3 +18,6 @@ ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_A QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 421bc0cdf..4dad7c099 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -18,3 +18,6 @@ ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_A QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) diff --git a/saas/Dockerfile b/saas/Dockerfile index 902f6685f..c30972ad1 100644 --- a/saas/Dockerfile +++ b/saas/Dockerfile @@ -40,8 +40,12 @@ COPY . . # Precompile bootsnap code for faster boot times RUN bundle exec bootsnap precompile app/ lib/ -# Precompiling assets for production without requiring secret RAILS_MASTER_KEY -RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile +# Precompiling assets for production +RUN SECRET_KEY_BASE_DUMMY=1 \ + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=1 \ + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=1 \ + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=1 \ + ./bin/rails assets:precompile # Final stage for app image FROM base diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index bc08b11da..c726c10b7 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -39,6 +39,9 @@ env: - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET - SENTRY_DSN + - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY + - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY + - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT tags: sc_chi: {} df_iad: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index e7da34310..1e66a4e96 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -45,6 +45,9 @@ env: - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET - SENTRY_DSN + - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY + - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY + - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index a0c777914..8dd91a092 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -45,6 +45,9 @@ env: - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET - SENTRY_DSN + - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY + - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY + - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/deploy.yml b/saas/config/deploy.yml index 71a7cabd7..34ad575dd 100644 --- a/saas/config/deploy.yml +++ b/saas/config/deploy.yml @@ -28,10 +28,6 @@ builder: remote: ssh://app@docker-builder-102 local: <%= ENV.fetch("KAMAL_BUILDER_LOCAL", "true") %> -env: - secret: - - RAILS_MASTER_KEY - aliases: - console: app exec -i --reuse "bin/rails console" - ssh: app exec -i --reuse /bin/bash + console: app exec -i --reuse -e CONSOLE_USER:<%= ENV["USER"] %> "bin/rails console" + ssh: app exec -i --reuse -e CONSOLE_USER:<%= ENV["USER"] %> /bin/bash From 23d7c48928c51a022e0eb2ce25cc3c12aedc836f Mon Sep 17 00:00:00 2001 From: Lewis Buckley Date: Wed, 3 Dec 2025 15:54:23 +0000 Subject: [PATCH 085/318] Upgrade prometheus-client-mmap to ~> 1.4.0 --- saas/fizzy-saas.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 6acc1bda2..7f993fbfd 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -34,5 +34,5 @@ Gem::Specification.new do |spec| spec.add_dependency "yabeda-prometheus-mmap" spec.add_dependency "yabeda-puma-plugin" spec.add_dependency "yabeda-rails", ">= 0.10" - spec.add_dependency "prometheus-client-mmap" + spec.add_dependency "prometheus-client-mmap", "~> 1.4.0" end From 3ab3bcc58042193cd181ef667346331028bd6f52 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 3 Dec 2025 16:36:55 -0500 Subject: [PATCH 086/318] Mark a development token as gitleaks:allow --- saas/lib/fizzy/saas/engine.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 06e2df911..878bf7a3f 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -85,7 +85,7 @@ module Fizzy Queenbee::Subscription.short_names = Subscription::SHORT_NAMES # Default to local dev QB token if not set - Queenbee::ApiToken.token = ENV.fetch("QUEENBEE_API_TOKEN") { "69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1" } + Queenbee::ApiToken.token = ENV.fetch("QUEENBEE_API_TOKEN") { "69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1" } # gitleaks:allow development-only token Subscription::SHORT_NAMES.each do |short_name| const_name = "#{short_name}Subscription" From 8f73e1e1be181ece391ca07fc600085e524fbedd Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 3 Dec 2025 16:13:36 -0500 Subject: [PATCH 087/318] Add `console1984` and `audits1984` To keep as much of this as we can in the `fizzy-saas` gem, this PR opts to store console/audits models in a separate database, and so: - the gem contains the migrations and the database config - the app contains a separate schema file distinctly for SaaS concerns - Rails' current behavior prevents us from easily keeping this file in the gem Also note that the stock `audits1984` schema is updated to reference the auditor via a UUID foreign key. Finally, in order for the database schema file to be located in the gem directory (and not the application directory), it's necessary to monkeypatch `AR::DatabaseTasks.schema_dump_path` to support absolute paths. This functionality has been proposed upstream in https://github.com/rails/rails/pull/56290 but is awaiting a decision from the core team. ref: https://app.fizzy.do/5986089/cards/2469 ref: #17 --- saas/app/controllers/saas_admin_controller.rb | 6 ++ saas/app/models/saas_record.rb | 5 ++ saas/config/database.yml | 22 +++++++ saas/config/routes.rb | 4 ++ ...9_create_console1984_tables.console1984.rb | 36 ++++++++++++ ...05753_create_auditing_tables.audits1984.rb | 13 +++++ saas/db/saas_schema.rb | 57 +++++++++++++++++++ saas/fizzy-saas.gemspec | 2 + saas/lib/fizzy/saas/engine.rb | 19 ++++++- .../active_record_tasks_database_tasks.rb | 23 ++++++++ 10 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 saas/app/controllers/saas_admin_controller.rb create mode 100644 saas/app/models/saas_record.rb create mode 100644 saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb create mode 100644 saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb create mode 100644 saas/db/saas_schema.rb create mode 100644 saas/lib/rails_ext/active_record_tasks_database_tasks.rb diff --git a/saas/app/controllers/saas_admin_controller.rb b/saas/app/controllers/saas_admin_controller.rb new file mode 100644 index 000000000..f68ca492e --- /dev/null +++ b/saas/app/controllers/saas_admin_controller.rb @@ -0,0 +1,6 @@ +class SaasAdminController < ::AdminController + private + def find_current_auditor + Current.identity + end +end diff --git a/saas/app/models/saas_record.rb b/saas/app/models/saas_record.rb new file mode 100644 index 000000000..37a1d8980 --- /dev/null +++ b/saas/app/models/saas_record.rb @@ -0,0 +1,5 @@ +class SaasRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: { writing: :saas, reading: :saas } +end diff --git a/saas/config/database.yml b/saas/config/database.yml index 3ef8f4b53..41be3a0e2 100644 --- a/saas/config/database.yml +++ b/saas/config/database.yml @@ -11,6 +11,8 @@ mysql_app_user = ENV[mysql_app_user_key] mysql_app_password = ENV[mysql_app_password_key] + + gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir %> default: &default @@ -48,6 +50,12 @@ development: database: development_queue port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> migrations_paths: db/queue_migrate + saas: + <<: *default + database: fizzy_saas_development + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: <%= File.join(gem_path, "db", "migrate") %> + schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". @@ -62,6 +70,12 @@ test: database: fizzy_test port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> replica: true + saas: + <<: *default + database: fizzy_saas_test + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: <%= File.join(gem_path, "db", "migrate") %> + schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> production: &production primary: @@ -98,6 +112,14 @@ production: &production username: <%= mysql_app_user %> password: <%= mysql_app_password %> migrations_paths: db/cache_migrate + saas: + <<: *default + database: fizzy_saas_production + host: <%= ENV["MYSQL_DATABASE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: <%= File.join(gem_path, "db", "migrate") %> + schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> beta: *production staging: *production diff --git a/saas/config/routes.rb b/saas/config/routes.rb index cb130af4f..7c7d4f6be 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -1,3 +1,7 @@ Fizzy::Saas::Engine.routes.draw do Queenbee.routes(self) + + namespace :admin do + mount Audits1984::Engine, at: "/console" + end end diff --git a/saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb b/saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb new file mode 100644 index 000000000..d03cc6248 --- /dev/null +++ b/saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb @@ -0,0 +1,36 @@ +# This migration comes from console1984 (originally 20210517203931) +class CreateConsole1984Tables < ActiveRecord::Migration[7.0] + def change + create_table :console1984_sessions do |t| + t.text :reason + t.references :user, null: false, index: false + t.timestamps + + t.index :created_at + t.index [ :user_id, :created_at ] + end + + create_table :console1984_users do |t| + t.string :username, null: false + t.timestamps + + t.index [:username] + end + + create_table :console1984_commands do |t| + t.text :statements + t.references :sensitive_access + t.references :session, null: false, index: false + t.timestamps + + t.index [ :session_id, :created_at, :sensitive_access_id ], name: "on_session_and_sensitive_chronologically" + end + + create_table :console1984_sensitive_accesses do |t| + t.text :justification + t.references :session, null: false + + t.timestamps + end + end +end diff --git a/saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb b/saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb new file mode 100644 index 000000000..431da136b --- /dev/null +++ b/saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb @@ -0,0 +1,13 @@ +# This migration comes from audits1984 (originally 20210810092639) +class CreateAuditingTables < ActiveRecord::Migration[7.0] + def change + create_table :audits1984_audits do |t| + t.integer :status, default: 0, null: false + t.text :notes + t.references :session, null: false + t.uuid :auditor_id, null: false + + t.timestamps + end + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb new file mode 100644 index 000000000..19d468927 --- /dev/null +++ b/saas/db/saas_schema.rb @@ -0,0 +1,57 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.2].define(version: 2025_12_02_205753) do + create_table "audits1984_audits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "auditor_id", null: false + t.datetime "created_at", null: false + t.text "notes" + t.bigint "session_id", null: false + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["session_id"], name: "index_audits1984_audits_on_session_id" + end + + create_table "console1984_commands", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "sensitive_access_id" + t.bigint "session_id", null: false + t.text "statements" + t.datetime "updated_at", null: false + t.index ["sensitive_access_id"], name: "index_console1984_commands_on_sensitive_access_id" + t.index ["session_id", "created_at", "sensitive_access_id"], name: "on_session_and_sensitive_chronologically" + end + + create_table "console1984_sensitive_accesses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "justification" + t.bigint "session_id", null: false + t.datetime "updated_at", null: false + t.index ["session_id"], name: "index_console1984_sensitive_accesses_on_session_id" + end + + create_table "console1984_sessions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "reason" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["created_at"], name: "index_console1984_sessions_on_created_at" + t.index ["user_id", "created_at"], name: "index_console1984_sessions_on_user_id_and_created_at" + end + + create_table "console1984_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "username", null: false + t.index ["username"], name: "index_console1984_users_on_username" + end +end diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 7f993fbfd..64f253588 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -35,4 +35,6 @@ Gem::Specification.new do |spec| spec.add_dependency "yabeda-puma-plugin" spec.add_dependency "yabeda-rails", ">= 0.10" spec.add_dependency "prometheus-client-mmap", "~> 1.4.0" + spec.add_dependency "console1984" + spec.add_dependency "audits1984" end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 878bf7a3f..ede0cbfcf 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -1,5 +1,6 @@ require_relative "transaction_pinning" require_relative "signup" +require_relative "../../rails_ext/active_record_tasks_database_tasks.rb" module Fizzy module Saas @@ -41,7 +42,7 @@ module Fizzy # Load test mocks automatically in test environment initializer "fizzy_saas.test_mocks", after: :load_config_initializers do if Rails.env.test? - require "fizzy/saas/testing" + require_relative "testing" end end @@ -79,6 +80,22 @@ module Fizzy require_relative "metrics" end + config.before_initialize do + config.console1984.protected_environments = %i[ production beta staging ] + config.console1984.ask_for_username_if_empty = true + config.console1984.base_record_class = "::SaasRecord" + + config.audits1984.base_controller_class = "::SaasAdminController" + config.audits1984.auditor_class = "::Identity" + config.audits1984.auditor_name_attribute = :email_address + + if config.console1984.protected_environments.include?(Rails.env.to_sym) + config.active_record.encryption.primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") + config.active_record.encryption.deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") + config.active_record.encryption.key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") + end + end + config.to_prepare do ::Signup.prepend(Fizzy::Saas::Signup) diff --git a/saas/lib/rails_ext/active_record_tasks_database_tasks.rb b/saas/lib/rails_ext/active_record_tasks_database_tasks.rb new file mode 100644 index 000000000..bafa27464 --- /dev/null +++ b/saas/lib/rails_ext/active_record_tasks_database_tasks.rb @@ -0,0 +1,23 @@ +module ActiveRecordTasksDatabaseTasksExtension + extend ActiveSupport::Concern + + class_methods do + # proposed upstream in https://github.com/rails/rails/pull/56290 + def schema_dump_path(db_config, format = db_config.schema_format) + return ENV["SCHEMA"] if ENV["SCHEMA"] + + filename = db_config.schema_dump(format) + return unless filename + + if Pathname.new(filename).absolute? + filename + else + super + end + end + end +end + +ActiveSupport.on_load(:active_record) do + ActiveRecord::Tasks::DatabaseTasks.include(ActiveRecordTasksDatabaseTasksExtension) +end From 8572fd77b85a4e865fc1fc5c410f813423180401 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 5 Dec 2025 10:57:41 -0800 Subject: [PATCH 088/318] Content Security Policy: report-only mode to Sentry (#21) --- saas/config/environments/production.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb index 27708a025..38acd1234 100644 --- a/saas/config/environments/production.rb +++ b/saas/config/environments/production.rb @@ -5,4 +5,8 @@ Rails.application.configure do config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" } config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" } config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false } + + # Content Security Policy: report-only mode, to Sentry + config.x.content_security_policy.report_only = true + config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" end From e35c1223f4d97db371987b2f98bc7eb3c7ed9f72 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 8 Dec 2025 09:39:35 -0800 Subject: [PATCH 089/318] Sentry: error context and release tracking (#22) * Sentry: use KAMAL_VERSION for release tracking Kamal injects KAMAL_VERSION at container runtime with the git SHA, so no need to bake it into the Docker image via build args. * Sentry: serve as Rails error reporter Sentry receives errors from Rails.error.report and Active Job `retry_on/discard_on report: true`. Error context is provided by Rails.error.set_context in Fizzy's Authentication concern and ApplicationJob. --- saas/lib/fizzy/saas/engine.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index ede0cbfcf..4da208a72 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -52,8 +52,11 @@ module Fizzy config.dsn = ENV["SENTRY_DSN"] config.breadcrumbs_logger = %i[ active_support_logger http_logger ] config.send_default_pii = false - config.release = ENV["GIT_REVISION"] + config.release = ENV["KAMAL_VERSION"] config.excluded_exceptions += [ "ActiveRecord::ConcurrentMigrationError" ] + + # Receive Rails.error.report and retry_on/discard_on report: true + config.rails.register_error_subscriber = true end end end From f0a28b15a98d99a2f3450d2059dc8c68224dbf34 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:09:21 +0100 Subject: [PATCH 090/318] retrieve sibling columns --- app/models/column.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/column.rb b/app/models/column.rb index 4c8358871..252ca6638 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -7,4 +7,8 @@ class Column < ApplicationRecord after_save_commit -> { cards.touch_all }, if: -> { saved_change_to_name? || saved_change_to_color? } after_destroy_commit -> { board.cards.touch_all } + + def sibling_columns + board.columns.where.not(id:).sorted + end end From eb7ef201ba851275dd38f10f8f50f77c4a3acca3 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:09:48 +0100 Subject: [PATCH 091/318] touch siblings after create and destroy commits --- app/models/column.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/column.rb b/app/models/column.rb index 252ca6638..0ff36a3b6 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -6,7 +6,8 @@ class Column < ApplicationRecord has_many :cards, dependent: :nullify after_save_commit -> { cards.touch_all }, if: -> { saved_change_to_name? || saved_change_to_color? } - after_destroy_commit -> { board.cards.touch_all } + after_create_commit -> { sibling_columns.touch_all } + after_destroy_commit -> { board.cards.touch_all; sibling_columns.touch_all } def sibling_columns board.columns.where.not(id:).sorted From b7042cfbd2a29d08640df7e6e26e50607cd957cf Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:11:25 +0100 Subject: [PATCH 092/318] create refresh sibling columns turbo stream partial --- app/views/columns/_refresh_siblings.turbo_stream.erb | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 app/views/columns/_refresh_siblings.turbo_stream.erb diff --git a/app/views/columns/_refresh_siblings.turbo_stream.erb b/app/views/columns/_refresh_siblings.turbo_stream.erb new file mode 100644 index 000000000..b1b9d1b52 --- /dev/null +++ b/app/views/columns/_refresh_siblings.turbo_stream.erb @@ -0,0 +1,4 @@ +<% column.sibling_columns.each do |sibling_column| %> + <%= turbo_stream.replace(dom_id(sibling_column), partial: "boards/show/column", method: :morph, locals: { column: sibling_column }) %> +<% end %> + From b06b8135d4fd1cd1a9f0f423a59c823eda7d8876 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:13:29 +0100 Subject: [PATCH 093/318] refresh sibling columns when moving column --- app/views/columns/left_positions/create.turbo_stream.erb | 1 + app/views/columns/right_positions/create.turbo_stream.erb | 1 + 2 files changed, 2 insertions(+) diff --git a/app/views/columns/left_positions/create.turbo_stream.erb b/app/views/columns/left_positions/create.turbo_stream.erb index 70729cf1b..f7e4cf56d 100644 --- a/app/views/columns/left_positions/create.turbo_stream.erb +++ b/app/views/columns/left_positions/create.turbo_stream.erb @@ -1,4 +1,5 @@ <% if @left_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.before(@left_column, partial: "boards/show/column", locals: { column: @column }) %> + <%= render "columns/refresh_siblings", column: @column %> <% end %> diff --git a/app/views/columns/right_positions/create.turbo_stream.erb b/app/views/columns/right_positions/create.turbo_stream.erb index a01ac7ce2..4ee425976 100644 --- a/app/views/columns/right_positions/create.turbo_stream.erb +++ b/app/views/columns/right_positions/create.turbo_stream.erb @@ -1,4 +1,5 @@ <% if @right_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.after(@right_column, partial: "boards/show/column", locals: { column: @column }) %> + <%= render "columns/refresh_siblings", column: @column %> <% end %> From 8a7abc37969c68be399fb7d553c38388d3c166c2 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:13:46 +0100 Subject: [PATCH 094/318] refresh sibling columns on column creation and deletion --- app/views/boards/columns/create.turbo_stream.erb | 1 + app/views/boards/columns/destroy.turbo_stream.erb | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/boards/columns/create.turbo_stream.erb b/app/views/boards/columns/create.turbo_stream.erb index 957547c09..6bb358856 100644 --- a/app/views/boards/columns/create.turbo_stream.erb +++ b/app/views/boards/columns/create.turbo_stream.erb @@ -1 +1,2 @@ <%= turbo_stream.before("closed-cards", partial: "boards/show/column", method: :morph, locals: { column: @column }) %> +<%= render "columns/refresh_siblings", column: @column %> diff --git a/app/views/boards/columns/destroy.turbo_stream.erb b/app/views/boards/columns/destroy.turbo_stream.erb index 7b6613a95..46b308037 100644 --- a/app/views/boards/columns/destroy.turbo_stream.erb +++ b/app/views/boards/columns/destroy.turbo_stream.erb @@ -1 +1,2 @@ -<%= turbo_stream.remove(dom_id(@column)) %> \ No newline at end of file +<%= turbo_stream.remove(dom_id(@column)) %> +<%= render "columns/refresh_siblings", column: @column %> \ No newline at end of file From 845d4a1a86370d1db9a763df690d41a2cdd6cb52 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:19:53 +0100 Subject: [PATCH 095/318] test left and right position controller updates --- .../columns/left_positions_controller_test.rb | 11 +++++++++++ .../columns/right_positions_controller_test.rb | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/test/controllers/columns/left_positions_controller_test.rb b/test/controllers/columns/left_positions_controller_test.rb index 57810ca70..2f31decd9 100644 --- a/test/controllers/columns/left_positions_controller_test.rb +++ b/test/controllers/columns/left_positions_controller_test.rb @@ -21,6 +21,17 @@ class Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest assert_equal original_position_a, column_b.reload.position end + test "move left refreshes sibling columns" do + column = columns(:writebook_in_progress) + sibling_columns = column.sibling_columns.to_a + + post column_left_position_path(column), as: :turbo_stream + + sibling_columns.each do |sibling_column| + assert_turbo_stream action: :replace, target: dom_id(sibling_column) + end + end + test "users can only reorder columns in boards they have access to" do column = columns(:writebook_in_progress) diff --git a/test/controllers/columns/right_positions_controller_test.rb b/test/controllers/columns/right_positions_controller_test.rb index 8226950ea..9bdf3099c 100644 --- a/test/controllers/columns/right_positions_controller_test.rb +++ b/test/controllers/columns/right_positions_controller_test.rb @@ -21,6 +21,17 @@ class Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest assert_equal original_position_a, column_b.reload.position end + test "move right refreshes sibling columns" do + column = columns(:writebook_in_progress) + sibling_columns = column.sibling_columns.to_a + + post column_right_position_path(column), as: :turbo_stream + + sibling_columns.each do |sibling_column| + assert_turbo_stream action: :replace, target: dom_id(sibling_column) + end + end + test "users can only reorder columns in boards they have access to" do column = columns(:writebook_triage) From 392c3680d2bcbf12f8f4a85dd0c886a07d2b689e Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:24:14 +0100 Subject: [PATCH 096/318] test board columns controller refreshes sibling columns --- .../boards/columns_controller_test.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index 7c6e7f525..c24cd1cb3 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -19,6 +19,17 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest assert_equal "New Column", boards(:writebook).columns.last.name end + test "create refreshes sibling columns" do + board = boards(:writebook) + existing_columns = board.columns.to_a + + post board_columns_path(board), params: { column: { name: "New Column" } }, as: :turbo_stream + + existing_columns.each do |column| + assert_turbo_stream action: :replace, target: dom_id(column) + end + end + test "update" do column = columns(:writebook_in_progress) @@ -36,4 +47,16 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest assert_response :success end end + + test "destroy refreshes sibling columns" do + board = boards(:writebook) + column = columns(:writebook_on_hold) + sibling_columns = column.sibling_columns.to_a + + delete board_column_path(board, column), as: :turbo_stream + + sibling_columns.each do |sibling_column| + assert_turbo_stream action: :replace, target: dom_id(sibling_column) + end + end end From 646aad8a504aa6547ebcc58a8838e19471ab1a2e Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Tue, 9 Dec 2025 10:26:16 +0100 Subject: [PATCH 097/318] Use development instead of local --- app/models/application_platform.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index 59d7c9f78..274806f29 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -82,7 +82,7 @@ class ApplicationPlatform < PlatformAgent def scoped_stylesheet_paths(scope = css_asset_name) # Allow new stylesheets to be added in dev/test without restarting server - SCOPED_STYLESHEET_PATHS.clear if Rails.env.local? + SCOPED_STYLESHEET_PATHS.clear if Rails.env.development? SCOPED_STYLESHEET_PATHS[scope] ||= Rails.root.join("app/assets/stylesheets").then do |stylesheet_root| From 072089a9ce86321479b0a350b6ba580c27da558a Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Tue, 9 Dec 2025 10:26:56 +0100 Subject: [PATCH 098/318] stylesheet_paths is the only public method --- app/models/application_platform.rb | 33 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index 274806f29..c484bf335 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -66,29 +66,30 @@ class ApplicationPlatform < PlatformAgent end end - def stylesheet_asset_name - case - when android_app? then "android" - when ios_app? then "ios" - else "desktop" - end - end - def stylesheet_paths scoped_stylesheet_paths("web") + (mobile_app? ? scoped_stylesheet_paths("mobile_app") : []) + scoped_stylesheet_paths(stylesheet_asset_name) end - def scoped_stylesheet_paths(scope = css_asset_name) - # Allow new stylesheets to be added in dev/test without restarting server + private + def stylesheet_asset_name + case + when android_app? then "android" + when ios_app? then "ios" + else "desktop" + end + end + + def scoped_stylesheet_paths(scope = css_asset_name) + # Allow new stylesheets to be added in dev/test without restarting server SCOPED_STYLESHEET_PATHS.clear if Rails.env.development? - SCOPED_STYLESHEET_PATHS[scope] ||= - Rails.root.join("app/assets/stylesheets").then do |stylesheet_root| - stylesheet_root.glob("#{scope}/**/*.css").collect do |path| - path.to_s.remove(stylesheet_root.to_s + "/", ".css") + SCOPED_STYLESHEET_PATHS[scope] ||= + Rails.root.join("app/assets/stylesheets").then do |stylesheet_root| + stylesheet_root.glob("#{scope}/**/*.css").collect do |path| + path.to_s.remove(stylesheet_root.to_s + "/", ".css") + end end - end - end + end end From 820e8ef5e4516e466f30ca881180f00a278b98c9 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 10:30:01 +0100 Subject: [PATCH 099/318] test creating/destroying columns touches siblings --- test/models/column/positioned_test.rb | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/models/column/positioned_test.rb b/test/models/column/positioned_test.rb index 3cd66e314..216fc9c68 100644 --- a/test/models/column/positioned_test.rb +++ b/test/models/column/positioned_test.rb @@ -59,4 +59,30 @@ class Column::PositionedTest < ActiveSupport::TestCase assert_equal original_position, rightmost_column.reload.position end + + test "creating a column touches sibling columns" do + board = boards(:writebook) + column = board.columns.first + original_updated_at = column.updated_at + + travel 1.second do + board.columns.create!(name: "New Column") + end + + assert_operator column.reload.updated_at, :>, original_updated_at + end + + test "destroying a column touches sibling columns" do + board = boards(:writebook) + columns = board.columns.sorted.to_a + column_to_keep = columns.first + column_to_destroy = columns.last + original_updated_at = column_to_keep.updated_at + + travel 1.second do + column_to_destroy.destroy + end + + assert_operator column_to_keep.reload.updated_at, :>, original_updated_at + end end From 5d1ce5a656bf72d3acb35c13d7b4fcfb25801ad6 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 11:07:22 +0100 Subject: [PATCH 100/318] rename from sibling_columns to surroundings --- app/models/column.rb | 7 +------ app/models/column/positioned.rb | 6 ++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/models/column.rb b/app/models/column.rb index 0ff36a3b6..4c8358871 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -6,10 +6,5 @@ class Column < ApplicationRecord has_many :cards, dependent: :nullify after_save_commit -> { cards.touch_all }, if: -> { saved_change_to_name? || saved_change_to_color? } - after_create_commit -> { sibling_columns.touch_all } - after_destroy_commit -> { board.cards.touch_all; sibling_columns.touch_all } - - def sibling_columns - board.columns.where.not(id:).sorted - end + after_destroy_commit -> { board.cards.touch_all } end diff --git a/app/models/column/positioned.rb b/app/models/column/positioned.rb index 8518172de..ede57d139 100644 --- a/app/models/column/positioned.rb +++ b/app/models/column/positioned.rb @@ -5,6 +5,8 @@ module Column::Positioned scope :sorted, -> { order(position: :asc) } before_create :set_position + after_create_commit -> { surroundings.touch_all } + after_destroy_commit -> { surroundings.touch_all } end def move_left @@ -31,6 +33,10 @@ module Column::Positioned right_column.nil? end + def surroundings + board.columns.where(id: [ left_column&.id, right_column&.id ].compact) + end + private def set_position max_position = board.columns.maximum(:position) || 0 From fc02b664dbece7db29afed3ec5dcb6177c08651e Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 11:08:37 +0100 Subject: [PATCH 101/318] update refresh logic to target surroundings only --- app/views/columns/_refresh_siblings.turbo_stream.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/columns/_refresh_siblings.turbo_stream.erb b/app/views/columns/_refresh_siblings.turbo_stream.erb index b1b9d1b52..605107788 100644 --- a/app/views/columns/_refresh_siblings.turbo_stream.erb +++ b/app/views/columns/_refresh_siblings.turbo_stream.erb @@ -1,4 +1,4 @@ -<% column.sibling_columns.each do |sibling_column| %> - <%= turbo_stream.replace(dom_id(sibling_column), partial: "boards/show/column", method: :morph, locals: { column: sibling_column }) %> +<% column.surroundings.each do |surrounding| %> + <%= turbo_stream.replace(dom_id(surrounding), partial: "boards/show/column", method: :morph, locals: { column: surrounding }) %> <% end %> From 8e6e761798319aa4ab0a0d6e5870cfae216ed9fa Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 11:18:27 +0100 Subject: [PATCH 102/318] update views to use refresh_surroundings stream --- app/views/boards/columns/create.turbo_stream.erb | 2 +- app/views/boards/columns/destroy.turbo_stream.erb | 2 +- ....turbo_stream.erb => _refresh_surroundings.turbo_stream.erb} | 0 app/views/columns/left_positions/create.turbo_stream.erb | 2 +- app/views/columns/right_positions/create.turbo_stream.erb | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename app/views/columns/{_refresh_siblings.turbo_stream.erb => _refresh_surroundings.turbo_stream.erb} (100%) diff --git a/app/views/boards/columns/create.turbo_stream.erb b/app/views/boards/columns/create.turbo_stream.erb index 6bb358856..fad58eea1 100644 --- a/app/views/boards/columns/create.turbo_stream.erb +++ b/app/views/boards/columns/create.turbo_stream.erb @@ -1,2 +1,2 @@ <%= turbo_stream.before("closed-cards", partial: "boards/show/column", method: :morph, locals: { column: @column }) %> -<%= render "columns/refresh_siblings", column: @column %> +<%= render "columns/refresh_surroundings", column: @column %> diff --git a/app/views/boards/columns/destroy.turbo_stream.erb b/app/views/boards/columns/destroy.turbo_stream.erb index 46b308037..cf3f5c3db 100644 --- a/app/views/boards/columns/destroy.turbo_stream.erb +++ b/app/views/boards/columns/destroy.turbo_stream.erb @@ -1,2 +1,2 @@ <%= turbo_stream.remove(dom_id(@column)) %> -<%= render "columns/refresh_siblings", column: @column %> \ No newline at end of file +<%= render "columns/refresh_surroundings", column: @column %> diff --git a/app/views/columns/_refresh_siblings.turbo_stream.erb b/app/views/columns/_refresh_surroundings.turbo_stream.erb similarity index 100% rename from app/views/columns/_refresh_siblings.turbo_stream.erb rename to app/views/columns/_refresh_surroundings.turbo_stream.erb diff --git a/app/views/columns/left_positions/create.turbo_stream.erb b/app/views/columns/left_positions/create.turbo_stream.erb index f7e4cf56d..d973730f0 100644 --- a/app/views/columns/left_positions/create.turbo_stream.erb +++ b/app/views/columns/left_positions/create.turbo_stream.erb @@ -1,5 +1,5 @@ <% if @left_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.before(@left_column, partial: "boards/show/column", locals: { column: @column }) %> - <%= render "columns/refresh_siblings", column: @column %> + <%= render "columns/refresh_surroundings", column: @column %> <% end %> diff --git a/app/views/columns/right_positions/create.turbo_stream.erb b/app/views/columns/right_positions/create.turbo_stream.erb index 4ee425976..ced53c410 100644 --- a/app/views/columns/right_positions/create.turbo_stream.erb +++ b/app/views/columns/right_positions/create.turbo_stream.erb @@ -1,5 +1,5 @@ <% if @right_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.after(@right_column, partial: "boards/show/column", locals: { column: @column }) %> - <%= render "columns/refresh_siblings", column: @column %> + <%= render "columns/refresh_surroundings", column: @column %> <% end %> From 1f8c9aebfd73e7cd9213f850963b2e3d4a162d75 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Tue, 9 Dec 2025 11:22:51 +0100 Subject: [PATCH 103/318] update tests --- .../boards/columns_controller_test.rb | 19 +++++++++---------- .../columns/left_positions_controller_test.rb | 7 +++---- .../right_positions_controller_test.rb | 7 +++---- test/models/column/positioned_test.rb | 18 +++++++++++------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index c24cd1cb3..dab8ae5bd 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -19,13 +19,13 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest assert_equal "New Column", boards(:writebook).columns.last.name end - test "create refreshes sibling columns" do + test "create refreshes surrounding columns" do board = boards(:writebook) - existing_columns = board.columns.to_a post board_columns_path(board), params: { column: { name: "New Column" } }, as: :turbo_stream - existing_columns.each do |column| + new_column = board.columns.find_by!(name: "New Column") + new_column.surroundings.each do |column| assert_turbo_stream action: :replace, target: dom_id(column) end end @@ -48,15 +48,14 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest end end - test "destroy refreshes sibling columns" do - board = boards(:writebook) - column = columns(:writebook_on_hold) - sibling_columns = column.sibling_columns.to_a + test "destroy refreshes surrounding columns" do + column = columns(:writebook_in_progress) + surroundings = column.surroundings.to_a - delete board_column_path(board, column), as: :turbo_stream + delete board_column_path(column.board, column), as: :turbo_stream - sibling_columns.each do |sibling_column| - assert_turbo_stream action: :replace, target: dom_id(sibling_column) + surroundings.each do |surrounding| + assert_turbo_stream action: :replace, target: dom_id(surrounding) end end end diff --git a/test/controllers/columns/left_positions_controller_test.rb b/test/controllers/columns/left_positions_controller_test.rb index 2f31decd9..cce5b67ef 100644 --- a/test/controllers/columns/left_positions_controller_test.rb +++ b/test/controllers/columns/left_positions_controller_test.rb @@ -21,14 +21,13 @@ class Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest assert_equal original_position_a, column_b.reload.position end - test "move left refreshes sibling columns" do + test "move left refreshes surrounding columns" do column = columns(:writebook_in_progress) - sibling_columns = column.sibling_columns.to_a post column_left_position_path(column), as: :turbo_stream - sibling_columns.each do |sibling_column| - assert_turbo_stream action: :replace, target: dom_id(sibling_column) + column.reload.surroundings.each do |surrounding| + assert_turbo_stream action: :replace, target: dom_id(surrounding) end end diff --git a/test/controllers/columns/right_positions_controller_test.rb b/test/controllers/columns/right_positions_controller_test.rb index 9bdf3099c..16c3ebba9 100644 --- a/test/controllers/columns/right_positions_controller_test.rb +++ b/test/controllers/columns/right_positions_controller_test.rb @@ -21,14 +21,13 @@ class Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest assert_equal original_position_a, column_b.reload.position end - test "move right refreshes sibling columns" do + test "move right refreshes surrounding columns" do column = columns(:writebook_in_progress) - sibling_columns = column.sibling_columns.to_a post column_right_position_path(column), as: :turbo_stream - sibling_columns.each do |sibling_column| - assert_turbo_stream action: :replace, target: dom_id(sibling_column) + column.reload.surroundings.each do |surrounding| + assert_turbo_stream action: :replace, target: dom_id(surrounding) end end diff --git a/test/models/column/positioned_test.rb b/test/models/column/positioned_test.rb index 216fc9c68..4ef910977 100644 --- a/test/models/column/positioned_test.rb +++ b/test/models/column/positioned_test.rb @@ -60,9 +60,9 @@ class Column::PositionedTest < ActiveSupport::TestCase assert_equal original_position, rightmost_column.reload.position end - test "creating a column touches sibling columns" do + test "creating a column touches surrounding columns" do board = boards(:writebook) - column = board.columns.first + column = board.columns.sorted.last original_updated_at = column.updated_at travel 1.second do @@ -72,17 +72,21 @@ class Column::PositionedTest < ActiveSupport::TestCase assert_operator column.reload.updated_at, :>, original_updated_at end - test "destroying a column touches sibling columns" do + test "destroying a column touches surrounding columns" do board = boards(:writebook) columns = board.columns.sorted.to_a - column_to_keep = columns.first - column_to_destroy = columns.last - original_updated_at = column_to_keep.updated_at + column_to_destroy = columns[1] + left_neighbor = columns[0] + right_neighbor = columns[2] + + original_left_updated_at = left_neighbor.updated_at + original_right_updated_at = right_neighbor.updated_at travel 1.second do column_to_destroy.destroy end - assert_operator column_to_keep.reload.updated_at, :>, original_updated_at + assert_operator left_neighbor.reload.updated_at, :>, original_left_updated_at + assert_operator right_neighbor.reload.updated_at, :>, original_right_updated_at end end From 3836c666a1a5774f3e4a721a849fe7fa0628ba12 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 10 Dec 2025 14:13:54 -0600 Subject: [PATCH 104/318] Fix Lexxy prompt list padding by lowering rich-text specificity --- app/assets/stylesheets/lexxy.css | 9 ++------- app/assets/stylesheets/rich-text-content.css | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index f89028f02..17201dbbe 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -288,17 +288,12 @@ font-size: var(--text-small); list-style: none; margin: 0; + max-height: 200px; min-inline-size: var(--lexxy-prompt-min-width); + overflow: auto; padding: var(--lexxy-prompt-padding); visibility: hidden; z-index: var(--z-popup); - - max-height: 200px; - overflow: scroll; - - &:is(.lexxy-prompt-menu--visible) { - padding: var(--lexxy-prompt-padding); - } } .lexxy-prompt-menu--visible { diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index c9877f4bf..1918b04c6 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -1,5 +1,5 @@ @layer components { - .rich-text-content { + :where(.rich-text-content) { --block-margin: 0.5lh; :where(h1, h2, h3, h4, h5, h6) { From 0065408b8d9fa7dcbc4bd53e83c85dbe155cf10c Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 10 Dec 2025 14:24:20 -0600 Subject: [PATCH 105/318] Remove redundant :where() selectors and fix comment padding --- app/assets/stylesheets/comments.css | 5 +- app/assets/stylesheets/lexxy.css | 11 ++++- app/assets/stylesheets/rich-text-content.css | 52 ++++++++++---------- 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 80968184e..587749fab 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -48,7 +48,6 @@ } .comment__body { - padding-inline-end: var(--reaction-size); text-align: start; .action-text-content { @@ -60,6 +59,10 @@ margin-block-end: 0; } } + + &:not:has(lexxy-editor) { + padding-inline-end: var(--reaction-size); + } } .comment__content { diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 17201dbbe..f53e942cd 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -38,7 +38,7 @@ } .lexxy-editor__content { - margin: var(--block-space-half) 0; + margin-block-start: var(--block-space-half); min-block-size: calc(7lh + var(--block-space)); outline: 0; @@ -46,6 +46,10 @@ ::selection { background: oklch(var(--lch-blue-light) / 0.5); } + + > :last-child { + margin-block-end: 0; + } } .lexxy-editor--drag-over { @@ -218,12 +222,15 @@ [data-button-group] { display: flex; flex-direction: row; - flex-wrap: wrap; gap: var(--gap); + & { margin-block-start: var(--gap); } + + @media (max-width: 639px) { + flex-wrap: wrap; + } } } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 1918b04c6..202466221 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -2,7 +2,7 @@ :where(.rich-text-content) { --block-margin: 0.5lh; - :where(h1, h2, h3, h4, h5, h6) { + h1, h2, h3, h4, h5, h6 { display: block; font-weight: 800; letter-spacing: -0.02ch; @@ -12,14 +12,14 @@ text-wrap: balance; } - :where(h1) { font-size: 2em; } - :where(h2) { font-size: 1.5em; } - :where(h3) { font-size: 1.17em; } - :where(h4) { font-size: 1em; } - :where(h5) { font-size: 0.83em; } - :where(h6) { font-size: 0.67em; } + h1 { font-size: 2em; } + h2 { font-size: 1.5em; } + h3 { font-size: 1.17em; } + h4 { font-size: 1em; } + h5 { font-size: 0.83em; } + h6 { font-size: 0.67em; } - :where(p, ul, ol, dl, blockquote, figure, .attachment) { + p, ul, ol, dl, blockquote, figure, .attachment { margin-block: 0 var(--block-margin); &:not(lexxy-editor &) { @@ -28,48 +28,53 @@ } } - :where(ol, ul) { + ol, ul { padding-inline-start: 3ch; } - :where(li:has(li)) { + li:has(li) { list-style: none; - :where(ol, ul) { + ol, ul { margin: 0; padding-inline-start: 2ch; } } - :where(b, strong, .lexxy-content__bold) { + b, strong, .lexxy-content__bold { font-weight: 700; } - :where(i, em, .lexxy-content__italic) { + i, em, .lexxy-content__italic { font-style: italic; } - :where(s, .lexxy-content__strikethrough) { + s, .lexxy-content__strikethrough { text-decoration: line-through; } - :where(mark, .lexxy-content__highlight) { + mark, .lexxy-content__highlight { background-color: transparent; color: inherit; } - :where(p, blockquote) { + p, blockquote { letter-spacing: -0.005ch; } - :where(blockquote) { + /* Avoid extra space due to empty paragraphs */ + p:empty { + display: none; + } + + blockquote { border-inline-start: 0.25em solid var(--color-ink-lighter); font-style: italic; margin: var(--block-margin) 0; padding-inline-start: 2ch; } - :where(img, video, embed, object) { + img, video, embed, object { inline-size: auto; margin-inline: auto; max-block-size: 32rem; @@ -82,12 +87,7 @@ inline-size: fit-content; } - /* Avoid extra space due to empty paragraphs */ - p:empty { - display: none; - } - - :where(hr) { + hr { color: currentColor; block-size: 0; border: none; @@ -102,7 +102,7 @@ } /* Code */ - :where(code, pre) { + code, pre { background-color: var(--color-canvas); border: 1px solid var(--color-ink-lighter); border-radius: 0.3em; @@ -208,7 +208,7 @@ position: relative; max-inline-size: 100%; - :where(progress) { + progress { inline-size: 100%; margin: auto; } From 219f4d60b6a9cba33d9310b412d71e711f060ad5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 10 Dec 2025 15:01:32 -0800 Subject: [PATCH 106/318] Prefer FQDN --- saas/.kamal/hooks/pre-connect | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/.kamal/hooks/pre-connect b/saas/.kamal/hooks/pre-connect index 5f42f2cf2..39c98c167 100755 --- a/saas/.kamal/hooks/pre-connect +++ b/saas/.kamal/hooks/pre-connect @@ -54,7 +54,7 @@ fi # Verify SSH access echo "Deploying via Tailscale. Verifying SSH access…" >&2 -TEST_HOST="fizzy-app-101" +TEST_HOST="fizzy-app-101.df-iad-int.37signals.com" SSH_OUTPUT=$(ssh -o ConnectTimeout=5 "app@$TEST_HOST" true 2>&1) SSH_EXIT=$? From 59d77cf104f497e0c7e9a2565ef408d547df6e74 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 10 Dec 2025 17:11:36 -0800 Subject: [PATCH 107/318] CSP: move SaaS-only sources out of OSS (#24) --- saas/config/environments/production.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb index 38acd1234..b2fa8b17f 100644 --- a/saas/config/environments/production.rb +++ b/saas/config/environments/production.rb @@ -6,7 +6,10 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" } config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false } - # Content Security Policy: report-only mode, to Sentry + # Content Security Policy config.x.content_security_policy.report_only = true config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" + config.x.content_security_policy.script_src = "https://challenges.cloudflare.com" + config.x.content_security_policy.frame_src = "https://challenges.cloudflare.com" + config.x.content_security_policy.connect_src = "https://storage.basecamp.com" end From 221e596c5deb22251387af14b94641f64f60bdfa Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 10 Dec 2025 17:36:23 -0800 Subject: [PATCH 108/318] Enforce CSP (#25) --- saas/config/environments/production.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb index b2fa8b17f..ee88639ad 100644 --- a/saas/config/environments/production.rb +++ b/saas/config/environments/production.rb @@ -7,7 +7,7 @@ Rails.application.configure do config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false } # Content Security Policy - config.x.content_security_policy.report_only = true + config.x.content_security_policy.report_only = false config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" config.x.content_security_policy.script_src = "https://challenges.cloudflare.com" config.x.content_security_policy.frame_src = "https://challenges.cloudflare.com" From fb0b787f10c667aa21b6ff0295cd2fd2f223c02e Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Thu, 11 Dec 2025 10:25:17 +0100 Subject: [PATCH 109/318] rename surroundings to adjacent columns --- app/models/column/positioned.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/column/positioned.rb b/app/models/column/positioned.rb index ede57d139..d0862d1cf 100644 --- a/app/models/column/positioned.rb +++ b/app/models/column/positioned.rb @@ -33,7 +33,7 @@ module Column::Positioned right_column.nil? end - def surroundings + def adjacent_columns board.columns.where(id: [ left_column&.id, right_column&.id ].compact) end From 52fcca9438d5ddce30e4aa482dfa2e1bd75d99e8 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Thu, 11 Dec 2025 10:25:34 +0100 Subject: [PATCH 110/318] remove unnecessary callback --- app/models/column/positioned.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/models/column/positioned.rb b/app/models/column/positioned.rb index d0862d1cf..c6ef105da 100644 --- a/app/models/column/positioned.rb +++ b/app/models/column/positioned.rb @@ -5,8 +5,6 @@ module Column::Positioned scope :sorted, -> { order(position: :asc) } before_create :set_position - after_create_commit -> { surroundings.touch_all } - after_destroy_commit -> { surroundings.touch_all } end def move_left From 93eac3aa239a3b3d6eccbcb5a41569f87e24fcc5 Mon Sep 17 00:00:00 2001 From: hiendinhngoc Date: Thu, 11 Dec 2025 16:17:37 +0700 Subject: [PATCH 111/318] docs: add missing `kamal init` step to deploy instructions --- .gitignore | 2 ++ README.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 38bd80d0d..8ef4e9012 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ /config/credentials/*.key .DS_Store + +.kamal/* \ No newline at end of file diff --git a/README.md b/README.md index 27ba149fc..100d46b0b 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ This repo contains a starter deployment file that you can modify for your own sp The steps to configure your very own Fizzy are: 1. Fork the repo -2. Edit few things in config/deploy.yml and .kamal/secrets -3. Run `kamal setup` to do your first deploy. +2. Initialize Kamal by running `kamal init`. This command generates the `.kamal` directory along with the required configuration files, including `.kamal/secrets`. +3. Edit few things in `config/deploy.yml` and `.kamal/secrets` +4. Run `kamal setup` to do your first deploy. We'll go through each of these in turn. From 383c5181a9184e8f8e85c941fc02a717ef495b2e Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Thu, 11 Dec 2025 10:28:29 +0100 Subject: [PATCH 112/318] rename file from refresh surroundings to refresh adjacent columns --- app/views/columns/_refresh_adjacent_columns.turbo_stream.erb | 3 +++ app/views/columns/_refresh_surroundings.turbo_stream.erb | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 app/views/columns/_refresh_adjacent_columns.turbo_stream.erb delete mode 100644 app/views/columns/_refresh_surroundings.turbo_stream.erb diff --git a/app/views/columns/_refresh_adjacent_columns.turbo_stream.erb b/app/views/columns/_refresh_adjacent_columns.turbo_stream.erb new file mode 100644 index 000000000..958902608 --- /dev/null +++ b/app/views/columns/_refresh_adjacent_columns.turbo_stream.erb @@ -0,0 +1,3 @@ +<% column.adjacent_columns.each do |adjacent_column| %> + <%= turbo_stream.replace(dom_id(adjacent_column), partial: "boards/show/column", method: :morph, locals: { column: adjacent_column }) %> +<% end %> diff --git a/app/views/columns/_refresh_surroundings.turbo_stream.erb b/app/views/columns/_refresh_surroundings.turbo_stream.erb deleted file mode 100644 index 605107788..000000000 --- a/app/views/columns/_refresh_surroundings.turbo_stream.erb +++ /dev/null @@ -1,4 +0,0 @@ -<% column.surroundings.each do |surrounding| %> - <%= turbo_stream.replace(dom_id(surrounding), partial: "boards/show/column", method: :morph, locals: { column: surrounding }) %> -<% end %> - From 2e629db9d6dc77c1ea3b805d0ccbe831f58765f0 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Thu, 11 Dec 2025 10:29:25 +0100 Subject: [PATCH 113/318] rename to use refresh adjacent columns turbo stream --- app/views/boards/columns/create.turbo_stream.erb | 2 +- app/views/boards/columns/destroy.turbo_stream.erb | 2 +- app/views/columns/left_positions/create.turbo_stream.erb | 2 +- app/views/columns/right_positions/create.turbo_stream.erb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/boards/columns/create.turbo_stream.erb b/app/views/boards/columns/create.turbo_stream.erb index fad58eea1..10ce7df7a 100644 --- a/app/views/boards/columns/create.turbo_stream.erb +++ b/app/views/boards/columns/create.turbo_stream.erb @@ -1,2 +1,2 @@ <%= turbo_stream.before("closed-cards", partial: "boards/show/column", method: :morph, locals: { column: @column }) %> -<%= render "columns/refresh_surroundings", column: @column %> +<%= render "columns/refresh_adjacent_columns", column: @column %> diff --git a/app/views/boards/columns/destroy.turbo_stream.erb b/app/views/boards/columns/destroy.turbo_stream.erb index cf3f5c3db..8181798cc 100644 --- a/app/views/boards/columns/destroy.turbo_stream.erb +++ b/app/views/boards/columns/destroy.turbo_stream.erb @@ -1,2 +1,2 @@ <%= turbo_stream.remove(dom_id(@column)) %> -<%= render "columns/refresh_surroundings", column: @column %> +<%= render "columns/refresh_adjacent_columns", column: @column %> diff --git a/app/views/columns/left_positions/create.turbo_stream.erb b/app/views/columns/left_positions/create.turbo_stream.erb index d973730f0..75938e3ff 100644 --- a/app/views/columns/left_positions/create.turbo_stream.erb +++ b/app/views/columns/left_positions/create.turbo_stream.erb @@ -1,5 +1,5 @@ <% if @left_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.before(@left_column, partial: "boards/show/column", locals: { column: @column }) %> - <%= render "columns/refresh_surroundings", column: @column %> + <%= render "columns/refresh_adjacent_columns", column: @column %> <% end %> diff --git a/app/views/columns/right_positions/create.turbo_stream.erb b/app/views/columns/right_positions/create.turbo_stream.erb index ced53c410..cabe87fc0 100644 --- a/app/views/columns/right_positions/create.turbo_stream.erb +++ b/app/views/columns/right_positions/create.turbo_stream.erb @@ -1,5 +1,5 @@ <% if @right_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.after(@right_column, partial: "boards/show/column", locals: { column: @column }) %> - <%= render "columns/refresh_surroundings", column: @column %> + <%= render "columns/refresh_adjacent_columns", column: @column %> <% end %> From 51d61b5cf88cd82c3df69573b4c111612d41afc1 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Thu, 11 Dec 2025 10:36:56 +0100 Subject: [PATCH 114/318] update tests --- .../boards/columns_controller_test.rb | 15 +++++----- .../columns/left_positions_controller_test.rb | 6 ++-- .../right_positions_controller_test.rb | 6 ++-- test/models/column/positioned_test.rb | 30 ------------------- 4 files changed, 14 insertions(+), 43 deletions(-) diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index 5fa4cebaf..afce7d5dc 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -19,14 +19,14 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest assert_equal "New Column", boards(:writebook).columns.last.name end - test "create refreshes surrounding columns" do + test "create refreshes adjacent columns" do board = boards(:writebook) post board_columns_path(board), params: { column: { name: "New Column" } }, as: :turbo_stream new_column = board.columns.find_by!(name: "New Column") - new_column.surroundings.each do |column| - assert_turbo_stream action: :replace, target: dom_id(column) + new_column.adjacent_columns.each do |adjacent_column| + assert_turbo_stream action: :replace, target: dom_id(adjacent_column) end end @@ -49,15 +49,16 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest end - test "destroy refreshes surrounding columns" do + test "destroy refreshes adjacent columns" do column = columns(:writebook_in_progress) - surroundings = column.surroundings.to_a + adjacent_columns = column.adjacent_columns.to_a delete board_column_path(column.board, column), as: :turbo_stream - surroundings.each do |surrounding| - assert_turbo_stream action: :replace, target: dom_id(surrounding) + adjacent_columns.each do |adjacent_column| + assert_turbo_stream action: :replace, target: dom_id(adjacent_column) end + end test "index as JSON" do board = boards(:writebook) diff --git a/test/controllers/columns/left_positions_controller_test.rb b/test/controllers/columns/left_positions_controller_test.rb index cce5b67ef..6c807b0e5 100644 --- a/test/controllers/columns/left_positions_controller_test.rb +++ b/test/controllers/columns/left_positions_controller_test.rb @@ -21,13 +21,13 @@ class Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest assert_equal original_position_a, column_b.reload.position end - test "move left refreshes surrounding columns" do + test "move left refreshes adjacent columns" do column = columns(:writebook_in_progress) post column_left_position_path(column), as: :turbo_stream - column.reload.surroundings.each do |surrounding| - assert_turbo_stream action: :replace, target: dom_id(surrounding) + column.reload.adjacent_columns.each do |adjacent_column| + assert_turbo_stream action: :replace, target: dom_id(adjacent_column) end end diff --git a/test/controllers/columns/right_positions_controller_test.rb b/test/controllers/columns/right_positions_controller_test.rb index 16c3ebba9..c927531be 100644 --- a/test/controllers/columns/right_positions_controller_test.rb +++ b/test/controllers/columns/right_positions_controller_test.rb @@ -21,13 +21,13 @@ class Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest assert_equal original_position_a, column_b.reload.position end - test "move right refreshes surrounding columns" do + test "move right refreshes adjacent columns" do column = columns(:writebook_in_progress) post column_right_position_path(column), as: :turbo_stream - column.reload.surroundings.each do |surrounding| - assert_turbo_stream action: :replace, target: dom_id(surrounding) + column.reload.adjacent_columns.each do |adjacent_column| + assert_turbo_stream action: :replace, target: dom_id(adjacent_column) end end diff --git a/test/models/column/positioned_test.rb b/test/models/column/positioned_test.rb index 4ef910977..3cd66e314 100644 --- a/test/models/column/positioned_test.rb +++ b/test/models/column/positioned_test.rb @@ -59,34 +59,4 @@ class Column::PositionedTest < ActiveSupport::TestCase assert_equal original_position, rightmost_column.reload.position end - - test "creating a column touches surrounding columns" do - board = boards(:writebook) - column = board.columns.sorted.last - original_updated_at = column.updated_at - - travel 1.second do - board.columns.create!(name: "New Column") - end - - assert_operator column.reload.updated_at, :>, original_updated_at - end - - test "destroying a column touches surrounding columns" do - board = boards(:writebook) - columns = board.columns.sorted.to_a - column_to_destroy = columns[1] - left_neighbor = columns[0] - right_neighbor = columns[2] - - original_left_updated_at = left_neighbor.updated_at - original_right_updated_at = right_neighbor.updated_at - - travel 1.second do - column_to_destroy.destroy - end - - assert_operator left_neighbor.reload.updated_at, :>, original_left_updated_at - assert_operator right_neighbor.reload.updated_at, :>, original_right_updated_at - end end From 5ece8f7c33b1a57d41fc75298afce153a86f9f14 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Thu, 11 Dec 2025 10:42:44 +0100 Subject: [PATCH 115/318] replace destroy column turbo stream for redirect_back_or_to --- app/controllers/boards/columns_controller.rb | 2 +- app/views/boards/columns/destroy.turbo_stream.erb | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 app/views/boards/columns/destroy.turbo_stream.erb diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 9e14eb937..4b71e7f3e 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -35,7 +35,7 @@ class Boards::ColumnsController < ApplicationController @column.destroy respond_to do |format| - format.turbo_stream + format.html { redirect_back_or_to @board } format.json { head :no_content } end end diff --git a/app/views/boards/columns/destroy.turbo_stream.erb b/app/views/boards/columns/destroy.turbo_stream.erb deleted file mode 100644 index 8181798cc..000000000 --- a/app/views/boards/columns/destroy.turbo_stream.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= turbo_stream.remove(dom_id(@column)) %> -<%= render "columns/refresh_adjacent_columns", column: @column %> From 5eda42f2dd8ffcfc6057d8eae9b1bb115c5dd833 Mon Sep 17 00:00:00 2001 From: Tomas Costantino Date: Thu, 11 Dec 2025 10:49:05 +0100 Subject: [PATCH 116/318] update columns controller destroy test --- test/controllers/boards/columns_controller_test.rb | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/test/controllers/boards/columns_controller_test.rb b/test/controllers/boards/columns_controller_test.rb index afce7d5dc..b9c974321 100644 --- a/test/controllers/boards/columns_controller_test.rb +++ b/test/controllers/boards/columns_controller_test.rb @@ -40,24 +40,12 @@ class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest end test "destroy" do - column = columns(:writebook_on_hold) - - assert_difference -> { boards(:writebook).columns.count }, -1 do - delete board_column_path(boards(:writebook), column), as: :turbo_stream - assert_response :success - end - end - - - test "destroy refreshes adjacent columns" do column = columns(:writebook_in_progress) adjacent_columns = column.adjacent_columns.to_a delete board_column_path(column.board, column), as: :turbo_stream - adjacent_columns.each do |adjacent_column| - assert_turbo_stream action: :replace, target: dom_id(adjacent_column) - end + assert_redirected_to board_path(column.board) end test "index as JSON" do From 03ea5f7b89cf433eedd04b2b61e8172d16befd9c Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 11:38:28 +0000 Subject: [PATCH 117/318] Set multi_tenant config option To allow the non-SaaS version of Fizzy to default to single-tenant mode, we'll enable a `multi_tenant.enabled` config option here that overrides the default behaviour. --- saas/config/environments/production.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb index ee88639ad..7add7804b 100644 --- a/saas/config/environments/production.rb +++ b/saas/config/environments/production.rb @@ -6,6 +6,9 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" } config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false } + # SaaS version of Fizzy is multi-tenanted + config.x.multi_tenant.enabled = true + # Content Security Policy config.x.content_security_policy.report_only = false config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" From 54a9641dee5c3aa5b79a328b1c58b5fbf1d347c2 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 12:22:10 +0000 Subject: [PATCH 118/318] Also set multi_tenant for SaaS development --- saas/config/environments/development.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/saas/config/environments/development.rb b/saas/config/environments/development.rb index eb82f052b..ac3f546af 100644 --- a/saas/config/environments/development.rb +++ b/saas/config/environments/development.rb @@ -1,4 +1,7 @@ Rails.application.configure do + # SaaS version of Fizzy is multi-tenanted + config.x.multi_tenant.enabled = true + if Rails.root.join("tmp/structured-logging.txt").exist? config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") end From 88de372f414c8832d38fc2c85f3a9eab669df0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20=C3=81lvarez?= Date: Thu, 11 Dec 2025 17:36:06 +0100 Subject: [PATCH 119/318] Migrate Staging app URL to app.fizzy-staging.com --- saas/README.md | 2 +- saas/config/environments/staging.rb | 6 +++--- saas/script/configure-lb-staging.sh | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/saas/README.md b/saas/README.md index 0e0387708..90bf7096d 100644 --- a/saas/README.md +++ b/saas/README.md @@ -50,7 +50,7 @@ Beta tenant is: Staging is primarily intended for testing infrastructure changes. It uses production-like but separate database and Active Storage configurations. -- https://fizzy.37signals-staging.com/ +- https://app.fizzy-staging.com/ ## License diff --git a/saas/config/environments/staging.rb b/saas/config/environments/staging.rb index f23bc800b..7000d8f59 100644 --- a/saas/config/environments/staging.rb +++ b/saas/config/environments/staging.rb @@ -1,8 +1,8 @@ require_relative "production" Rails.application.configure do - config.action_mailer.smtp_settings[:domain] = "fizzy.37signals-staging.com" + config.action_mailer.smtp_settings[:domain] = "app.fizzy-staging.com" config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging" - config.action_mailer.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" } - config.action_controller.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" } + config.action_mailer.default_url_options = { host: "app.fizzy-staging.com", protocol: "https" } + config.action_controller.default_url_options = { host: "app.fizzy-staging.com", protocol: "https" } end diff --git a/saas/script/configure-lb-staging.sh b/saas/script/configure-lb-staging.sh index 3e88cbcb1..770014403 100755 --- a/saas/script/configure-lb-staging.sh +++ b/saas/script/configure-lb-staging.sh @@ -9,7 +9,7 @@ ssh app@fizzy-staging-lb-01.sc-chi-int.37signals.com \ kamal-proxy deploy fizzy \ --force \ --tls \ - --host=fizzy.37signals-staging.com \ + --host=app.fizzy-staging.com \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-staging-app-101.df-iad-int.37signals.com \ @@ -24,7 +24,7 @@ ssh app@fizzy-staging-lb-101.df-iad-int.37signals.com \ kamal-proxy deploy fizzy \ --force \ --tls \ - --host=fizzy.37signals-staging.com \ + --host=app.fizzy-staging.com \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-staging-app-101.df-iad-int.37signals.com \ @@ -37,7 +37,7 @@ ssh app@fizzy-staging-lb-401.df-ams-int.37signals.com \ kamal-proxy deploy fizzy \ --force \ --tls \ - --host=fizzy.37signals-staging.com \ + --host=app.fizzy-staging.com \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-staging-app-101.df-iad-int.37signals.com \ From f15121645ae72f796d66e9e1ffeceef8feecb5f2 Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Thu, 11 Dec 2025 11:31:29 -0800 Subject: [PATCH 120/318] Bump queenbee-plugin to pickup the staging host move. See https://3.basecamp.com/2914079/buckets/21350690/card_tables/cards/9367648417 --- saas/Gemfile | 2 +- saas/Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/saas/Gemfile b/saas/Gemfile index 531e8c25c..f978b0dbb 100644 --- a/saas/Gemfile +++ b/saas/Gemfile @@ -2,6 +2,6 @@ source "https://rubygems.org" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } # 37id and Queenbee integration -gem "queenbee", bc: "queenbee-plugin", ref: "eb01c697de1ad028afc65cc7d9b5345a7a8e849f" +gem "queenbee", bc: "queenbee-plugin", ref: "14312a940471e20617b38cdec7c092a01567d18b" gem "rails_structured_logging", bc: "rails-structured-logging" gem "activeresource", require: "active_resource" # needed by queenbee diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock index 3b9a7d5c0..0dacabb04 100644 --- a/saas/Gemfile.lock +++ b/saas/Gemfile.lock @@ -1,7 +1,7 @@ GIT remote: https://github.com/basecamp/queenbee-plugin - revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f - ref: eb01c697de1ad028afc65cc7d9b5345a7a8e849f + revision: 14312a940471e20617b38cdec7c092a01567d18b + ref: 14312a940471e20617b38cdec7c092a01567d18b specs: queenbee (3.2.0) activeresource From eec96ff3843c4a7593cd36ad5e963ede98e30ed5 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 25 Nov 2025 14:40:49 +0000 Subject: [PATCH 121/318] Serve own avatar from its own endpoint This allows us to have different cache controls depending on whether you're viewing your own avatar, or someone else's. Your own avatar will always be fresh, while other folks' avatars can be pulled from the CDN. --- app/controllers/my/avatars_controller.rb | 16 +++++++++ app/controllers/users/avatars_controller.rb | 13 +++----- app/helpers/avatars_helper.rb | 7 +++- app/views/my/avatars/show.svg.erb | 1 + app/views/users/_initials.svg.erb | 22 +++++++++++++ app/views/users/avatars/show.svg.erb | 23 +------------ config/routes.rb | 1 + .../controllers/my/avatars_controller_test.rb | 33 +++++++++++++++++++ .../users/avatars_controller_test.rb | 11 ++----- 9 files changed, 86 insertions(+), 41 deletions(-) create mode 100644 app/controllers/my/avatars_controller.rb create mode 100644 app/views/my/avatars/show.svg.erb create mode 100644 app/views/users/_initials.svg.erb create mode 100644 test/controllers/my/avatars_controller_test.rb diff --git a/app/controllers/my/avatars_controller.rb b/app/controllers/my/avatars_controller.rb new file mode 100644 index 000000000..70f714ae2 --- /dev/null +++ b/app/controllers/my/avatars_controller.rb @@ -0,0 +1,16 @@ +class My::AvatarsController < ApplicationController + def show + if stale? Current.user + if Current.user.avatar.attached? + redirect_to rails_blob_url(Current.user.avatar_thumbnail, disposition: "inline") + else + render_initials + end + end + end + + private + def render_initials + render formats: :svg + end +end diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index d85e5f869..989387981 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -7,17 +7,20 @@ class Users::AvatarsController < ApplicationController before_action :ensure_permission_to_administer_user, only: :destroy def show + expires_in 30.minutes, public: true, stale_while_revalidate: 1.week + if @user.system? redirect_to view_context.image_path("system_user.png") elsif @user.avatar.attached? redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline") - elsif stale? @user, cache_control: cache_control + else render_initials end end def destroy @user.avatar.destroy + @user.touch redirect_to @user end @@ -30,14 +33,6 @@ class Users::AvatarsController < ApplicationController head :forbidden unless Current.user.can_change?(@user) end - def cache_control - if @user == Current.user - {} - else - { max_age: 30.minutes, stale_while_revalidate: 1.week } - end - end - def render_initials render formats: :svg end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 7c064a650..a30f20962 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -42,6 +42,11 @@ module AvatarsHelper end def avatar_image_tag(user, **options) - image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options + tag.span data: { creator_id: user.id } do + safe_join [ + image_tag(user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, data: { only_visible_to_others: true }, **options), + image_tag(my_avatar_url(script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, data: { only_visible_to_you: true }, **options) + ] + end end end diff --git a/app/views/my/avatars/show.svg.erb b/app/views/my/avatars/show.svg.erb new file mode 100644 index 000000000..adf20ff54 --- /dev/null +++ b/app/views/my/avatars/show.svg.erb @@ -0,0 +1 @@ +<%= render "users/initials", user: Current.user %> diff --git a/app/views/users/_initials.svg.erb b/app/views/users/_initials.svg.erb new file mode 100644 index 000000000..7d79adfb2 --- /dev/null +++ b/app/views/users/_initials.svg.erb @@ -0,0 +1,22 @@ + diff --git a/app/views/users/avatars/show.svg.erb b/app/views/users/avatars/show.svg.erb index 06b2dec0d..b6e34aeb7 100644 --- a/app/views/users/avatars/show.svg.erb +++ b/app/views/users/avatars/show.svg.erb @@ -1,22 +1 @@ - +<%= render "users/initials", user: @user %> diff --git a/config/routes.rb b/config/routes.rb index aa2815433..ce7d4fe90 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -164,6 +164,7 @@ Rails.application.routes.draw do resource :landing namespace :my do + resource :avatar, only: :show resource :identity, only: :show resources :access_tokens resources :pins diff --git a/test/controllers/my/avatars_controller_test.rb b/test/controllers/my/avatars_controller_test.rb new file mode 100644 index 000000000..c2081d121 --- /dev/null +++ b/test/controllers/my/avatars_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class My::AvatarsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + end + + test "show own initials" do + get my_avatar_path + assert_match "image/svg+xml", @response.content_type + assert_match "private", @response.headers["Cache-Control"] + assert_match "must-revalidate", @response.headers["Cache-Control"] + end + + test "show own image redirects to the blob url" do + users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg") + assert users(:david).avatar.attached? + + get my_avatar_path + + assert_redirected_to rails_blob_url(users(:david).avatar.variant(:thumb), disposition: "inline") + assert_match "private", @response.headers["Cache-Control"] + assert_match "must-revalidate", @response.headers["Cache-Control"] + end + + test "requires authentication" do + sign_out + + get my_avatar_path + + assert_response :redirect + end +end diff --git a/test/controllers/users/avatars_controller_test.rb b/test/controllers/users/avatars_controller_test.rb index 3ded884b8..783d5aeba 100644 --- a/test/controllers/users/avatars_controller_test.rb +++ b/test/controllers/users/avatars_controller_test.rb @@ -12,17 +12,10 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to ActionController::Base.helpers.image_path("system_user.png") end - test "show own initials without caching" do + test "show initials with public caching" do get user_avatar_path(users(:david)) assert_match "image/svg+xml", @response.content_type - assert @response.cache_control[:private] - assert_equal "0", @response.cache_control[:max_age] - end - - test "show other initials with caching" do - get user_avatar_path(users(:kevin)) - assert_match "image/svg+xml", @response.content_type - assert @response.cache_control[:private] + assert @response.cache_control[:public] assert_equal 30.minutes.to_s, @response.cache_control[:max_age] end From b5caa8716d0e402feed7fc5926456a215511e469 Mon Sep 17 00:00:00 2001 From: Abdelkader Boudih Date: Fri, 12 Dec 2025 10:12:21 +0100 Subject: [PATCH 122/318] feat: expose closed boolean in card JSON API Adds `closed` field to card JSON response, allowing API consumers to detect closed status without parsing the status enum or making additional API calls. --- app/views/cards/_card.json.jbuilder | 1 + test/controllers/cards_controller_test.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index a1c509a66..e5792f127 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -6,6 +6,7 @@ json.cache! card do json.tags card.tags.pluck(:title).sort + json.closed card.closed? json.golden card.golden? json.last_active_at card.last_active_at.utc json.created_at card.created_at.utc diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index 635b2d2e0..c6c56fc83 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -145,6 +145,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_equal card.title, @response.parsed_body["title"] + assert_equal card.closed?, @response.parsed_body["closed"] assert_equal 2, @response.parsed_body["steps"].size end From a12dfea3c8acfe2ae1c280f86030854922182bf3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 12 Dec 2025 10:50:51 -0500 Subject: [PATCH 123/318] Remove unpaired view-transition-name from public card show --- app/views/public/cards/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/public/cards/show.html.erb b/app/views/public/cards/show.html.erb index aa08fe149..7f9aca56c 100644 --- a/app/views/public/cards/show.html.erb +++ b/app/views/public/cards/show.html.erb @@ -14,7 +14,7 @@ <% content_for :header do %>
- <%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transition-name: card-board-title;" %> + <%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<% end %> From 09a2e7d7d7c9e1018a072e2d6ddfcb846d474257 Mon Sep 17 00:00:00 2001 From: Cengiz Guertusgil Date: Fri, 12 Dec 2025 17:21:57 +0100 Subject: [PATCH 124/318] feat: add rate limit to join codes controller --- app/controllers/join_codes_controller.rb | 1 + test/controllers/join_codes_controller_test.rb | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 8bd67c9dc..f2ec2588c 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -1,5 +1,6 @@ class JoinCodesController < ApplicationController allow_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { head :too_many_requests } before_action :set_join_code before_action :ensure_join_code_is_valid diff --git a/test/controllers/join_codes_controller_test.rb b/test/controllers/join_codes_controller_test.rb index 415bde3e5..81a4ea6ea 100644 --- a/test/controllers/join_codes_controller_test.rb +++ b/test/controllers/join_codes_controller_test.rb @@ -94,4 +94,12 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end end + + test "create is rate limited" do + Rails.cache.stubs(:increment).returns(11) + + post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "test@example.com" } + + assert_response :too_many_requests + end end From 430caed61e3b091ef2fc44e7ce7136b5db858837 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 11:52:30 -0500 Subject: [PATCH 125/318] saas: Bump queenbee gem for new staging location --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 0a40c4ecd..eaae95e75 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -50,7 +50,7 @@ GIT GIT remote: https://github.com/basecamp/queenbee-plugin - revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 + revision: 14312a940471e20617b38cdec7c092a01567d18b specs: queenbee (3.2.0) activeresource From 9649880b62a2344487c443e607e9646b4ccfa3f9 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Fri, 12 Dec 2025 11:03:26 -0600 Subject: [PATCH 126/318] Ensure images and videos are centered --- app/assets/stylesheets/rich-text-content.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 2d66de178..5a63f9eac 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -86,6 +86,7 @@ a:has(img), a:has(video) { inline-size: fit-content; + margin-inline: auto; } hr { From 412bf09e30f3759e18d4547803bbe40cc479d884 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 12 Dec 2025 12:07:36 -0500 Subject: [PATCH 127/318] Remove unused install.svg and its CSS class The install.svg file and .icon--install CSS class (which was duplicated) appear to have never been used. Added in c3b946c96. --- app/assets/images/install.svg | 1 - app/assets/stylesheets/icons.css | 2 -- 2 files changed, 3 deletions(-) delete mode 100644 app/assets/images/install.svg diff --git a/app/assets/images/install.svg b/app/assets/images/install.svg deleted file mode 100644 index 82608c765..000000000 --- a/app/assets/images/install.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 2e269b9d0..1ca7800fa 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -60,8 +60,6 @@ .icon--globe { --svg: url("globe.svg "); } .icon--golden-ticket { --svg: url("golden-ticket.svg "); } .icon--home { --svg: url("home.svg "); } - .icon--install { --svg: url("install.svg "); } - .icon--install { --svg: url("install.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } .icon--lock { --svg: url("lock.svg "); } From 6a244b17e6deef413d4f351fcd3c9b4e7d0118e0 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 13:04:26 -0500 Subject: [PATCH 128/318] Use new FIZZY_GH_TOKEN with limited access because this is a public repo and so doesn't have access to the private org secret GH_TOKEN anymore. --- .github/dependabot.yml | 2 +- .github/workflows/ci-saas.yml | 2 +- .github/workflows/test.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dbb544dc1..1efb99ec1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ registries: type: git url: https://github.com username: x-access-token - password: ${{secrets.GH_TOKEN}} + password: ${{secrets.FIZZY_GH_TOKEN}} updates: - package-ecosystem: bundler diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml index aa98a8e93..afb91c04d 100644 --- a/.github/workflows/ci-saas.yml +++ b/.github/workflows/ci-saas.yml @@ -18,4 +18,4 @@ jobs: with: saas: true secrets: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index beee16f15..777471774 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: type: boolean required: true secrets: - GH_TOKEN: + FIZZY_GH_TOKEN: required: false permissions: @@ -50,7 +50,7 @@ jobs: MYSQL_USER: root FIZZY_DB_HOST: 127.0.0.1 FIZZY_DB_PORT: 3306 - BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }} + BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} steps: - name: Install system packages From c9e9e7be55d2380f5a4ccfa003b4f491f522314f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 12 Dec 2025 20:40:47 +0100 Subject: [PATCH 129/318] Unused --- app/javascript/helpers/http_helpers.js | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 app/javascript/helpers/http_helpers.js diff --git a/app/javascript/helpers/http_helpers.js b/app/javascript/helpers/http_helpers.js deleted file mode 100644 index 1fb986f70..000000000 --- a/app/javascript/helpers/http_helpers.js +++ /dev/null @@ -1,4 +0,0 @@ -export const HttpStatus = { - CONFLICT: 409, - UNPROCESSABLE: 422 -} From d82d68fcd0cb5432e6d8b5138dc5ab8c7d1998c9 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 14:47:18 -0500 Subject: [PATCH 130/318] Restrict logins to employees in staging as inferred from the email domain, which must be "@37signals.com" or "@basecamp.com". --- saas/lib/fizzy/saas/authorization.rb | 28 ++++++++++ saas/lib/fizzy/saas/engine.rb | 4 ++ .../non_production_remote_access_test.rb | 56 +++++++++++++++++++ saas/test/models/identity_test.rb | 18 ++++++ 4 files changed, 106 insertions(+) create mode 100644 saas/lib/fizzy/saas/authorization.rb create mode 100644 saas/test/controllers/non_production_remote_access_test.rb create mode 100644 saas/test/models/identity_test.rb diff --git a/saas/lib/fizzy/saas/authorization.rb b/saas/lib/fizzy/saas/authorization.rb new file mode 100644 index 000000000..53117d98e --- /dev/null +++ b/saas/lib/fizzy/saas/authorization.rb @@ -0,0 +1,28 @@ +module Fizzy + module Saas + module Authorization + module Controller + extend ActiveSupport::Concern + + included do + before_action :ensure_only_employees_can_access_non_production_remote_environments, if: :authenticated? + end + + private + def ensure_only_employees_can_access_non_production_remote_environments + head :forbidden if Rails.env.staging? && !Current.identity.employee? + end + end + + module Identity + extend ActiveSupport::Concern + + EMPLOYEE_DOMAINS = ["@37signals.com", "@basecamp.com"].freeze + + def employee? + email_address.end_with?(*EMPLOYEE_DOMAINS) + end + end + end + end +end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 4da208a72..d9414b0cf 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -1,5 +1,6 @@ require_relative "transaction_pinning" require_relative "signup" +require_relative "authorization" require_relative "../../rails_ext/active_record_tasks_database_tasks.rb" module Fizzy @@ -112,6 +113,9 @@ module Fizzy ::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name) ::Object.const_set const_name, Subscription.const_get(short_name, false) end + + ::Authorization.include Fizzy::Saas::Authorization::Controller + ::Identity.include Fizzy::Saas::Authorization::Identity end end end diff --git a/saas/test/controllers/non_production_remote_access_test.rb b/saas/test/controllers/non_production_remote_access_test.rb new file mode 100644 index 000000000..40a9520d8 --- /dev/null +++ b/saas/test/controllers/non_production_remote_access_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest + test "employee can access in staging environment" do + assert_predicate identities(:david), :employee? + + sign_in_as :david + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) + get cards_path + assert_response :success + end + + test "non-employee cannot access in staging environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) + get cards_path + assert_response :forbidden + end + + test "non-employee can access in production environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production")) + get cards_path + assert_response :success + end + + test "non-employee can access in beta environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("beta")) + get cards_path + assert_response :success + end + + test "non-employee can access in local environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + get cards_path + assert_response :success + end +end diff --git a/saas/test/models/identity_test.rb b/saas/test/models/identity_test.rb new file mode 100644 index 000000000..81e3b82c1 --- /dev/null +++ b/saas/test/models/identity_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class Fizzy::Saas::IdentityTest < ActiveSupport::TestCase + test "#employee? returns true for 37signals.com domains" do + identity = Identity.new(email_address: "mike@37signals.com") + assert_predicate identity, :employee? + end + + test "#employee? returns true for basecamp.com domains" do + identity = Identity.new(email_address: "mike@basecamp.com") + assert_predicate identity, :employee? + end + + test "#employee? returns false for other domains" do + identity = Identity.new(email_address: "mike@example.com") + assert_not_predicate identity, :employee? + end +end From 9cff236f66a37eff5991e2438d1ab669c02183a2 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 14:46:48 -0500 Subject: [PATCH 131/318] Drop staff restriction in beta and staging because it was preventing testing of signups. --- app/controllers/concerns/authorization.rb | 5 --- .../non_production_remote_access_test.rb | 34 ------------------- 2 files changed, 39 deletions(-) delete mode 100644 test/controllers/non_production_remote_access_test.rb diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 81bd0c781..46769992e 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -3,7 +3,6 @@ module Authorization included do before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } - before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated? end class_methods do @@ -30,10 +29,6 @@ module Authorization redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? end - def ensure_only_staff_can_access_non_production_remote_environments - head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff? - end - def redirect_existing_user redirect_to root_path if Current.user end diff --git a/test/controllers/non_production_remote_access_test.rb b/test/controllers/non_production_remote_access_test.rb deleted file mode 100644 index cb84eda35..000000000 --- a/test/controllers/non_production_remote_access_test.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "test_helper" - -class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest - test "staff can access in staging environment" do - sign_in_as :david - - Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) - get cards_path - assert_response :success - end - - test "non-staff cannot access in staging environment" do - sign_in_as :jz - - Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) - get cards_path - assert_response :forbidden - end - - test "non-staff can access in production environment" do - sign_in_as :jz - - Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production")) - get cards_path - assert_response :success - end - - test "non-staff can access in local environment" do - sign_in_as :jz - - get cards_path - assert_response :success - end -end From acc90ec8eb016a85e866d1907e2a230e3fd9b212 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 15:24:30 -0500 Subject: [PATCH 132/318] Fix inclusion of Fizzy::Saas::Authorization::Controller --- saas/lib/fizzy/saas/authorization.rb | 2 +- saas/lib/fizzy/saas/engine.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/lib/fizzy/saas/authorization.rb b/saas/lib/fizzy/saas/authorization.rb index 53117d98e..2309d1352 100644 --- a/saas/lib/fizzy/saas/authorization.rb +++ b/saas/lib/fizzy/saas/authorization.rb @@ -17,7 +17,7 @@ module Fizzy module Identity extend ActiveSupport::Concern - EMPLOYEE_DOMAINS = ["@37signals.com", "@basecamp.com"].freeze + EMPLOYEE_DOMAINS = [ "@37signals.com", "@basecamp.com" ].freeze def employee? email_address.end_with?(*EMPLOYEE_DOMAINS) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index d9414b0cf..ad23bef74 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -114,7 +114,7 @@ module Fizzy ::Object.const_set const_name, Subscription.const_get(short_name, false) end - ::Authorization.include Fizzy::Saas::Authorization::Controller + ::ApplicationController.include Fizzy::Saas::Authorization::Controller ::Identity.include Fizzy::Saas::Authorization::Identity end end From 43fd8ab6912faaa908b40daf98e10a6e3415e643 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 15:31:22 -0500 Subject: [PATCH 133/318] Update fizzy-saas to get employee restriction in staging --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index eaae95e75..070d23a72 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 9c356dfeedbf5309f1a36ee3371f49604c9be188 + revision: 210e68d7092a78c892c6a14df59c2167de0682b1 specs: fizzy-saas (0.1.0) audits1984 From 7d6cf62665fafc87dfdecd83cbd1d33d1b269fb7 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 16:59:43 -0500 Subject: [PATCH 134/318] CSP config to allow Minio in development --- config/environments/development.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/environments/development.rb b/config/environments/development.rb index 3e4109dd3..4e6285263 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -34,6 +34,8 @@ Rails.application.configure do # Store uploaded files on the local file system (see config/storage.yml for options). if Rails.root.join("tmp/minio-dev.txt").exist? config.active_storage.service = :devminio + config.x.content_security_policy.connect_src = "http://minio.localhost:39000" + config.x.content_security_policy.img_src = "http://minio.localhost:39000" else config.active_storage.service = :local end From 88ab0beb24eb1372229615e8687688b3a5d21349 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 22:40:53 -0600 Subject: [PATCH 135/318] Make layout bulletproof --- app/assets/stylesheets/avatars.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/stylesheets/avatars.css b/app/assets/stylesheets/avatars.css index e3f15d4ab..9c55ef4e9 100644 --- a/app/assets/stylesheets/avatars.css +++ b/app/assets/stylesheets/avatars.css @@ -22,6 +22,11 @@ max-inline-size: 100%; object-fit: cover; } + + span[data-creator-id] { + display: grid; + place-items: center; + } } .avatar__form { From 43ab17df2fe3452dbd75b4c95458c50aff7bdfdd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 12 Dec 2025 21:47:10 -0800 Subject: [PATCH 136/318] Update tests with final method naming: record! -> record --- test/models/storage/entry_test.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/models/storage/entry_test.rb b/test/models/storage/entry_test.rb index cf5befd52..0a7665532 100644 --- a/test/models/storage/entry_test.rb +++ b/test/models/storage/entry_test.rb @@ -7,7 +7,7 @@ class Storage::EntryTest < ActiveSupport::TestCase @card = cards(:logo) end - test "record! creates entry with positive delta" do + test "record creates entry with positive delta" do assert_difference "Storage::Entry.count", +1 do entry = Storage::Entry.record \ account: @account, @@ -25,7 +25,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! creates entry with negative delta" do + test "record creates entry with negative delta" do entry = Storage::Entry.record \ account: @account, board: @board, @@ -37,7 +37,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_equal "detach", entry.operation end - test "record! returns nil and creates no entry when delta is zero" do + test "record returns nil and creates no entry when delta is zero" do assert_no_difference "Storage::Entry.count" do result = Storage::Entry.record \ account: @account, @@ -50,7 +50,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! accepts _id params for after_destroy_commit snapshots" do + test "record accepts _id params for after_destroy_commit snapshots" do entry = Storage::Entry.record \ account_id: @account.id, board_id: @board.id, @@ -65,7 +65,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_equal @card.id, entry.recordable_id end - test "record! creates entry without board" do + test "record creates entry without board" do entry = Storage::Entry.record \ account: @account, board: nil, @@ -76,7 +76,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_nil entry.board_id end - test "record! creates entry without recordable" do + test "record creates entry without recordable" do entry = Storage::Entry.record \ account: @account, board: @board, @@ -88,7 +88,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_nil entry.recordable_id end - test "record! enqueues MaterializeJob for account" do + test "record enqueues MaterializeJob for account" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do Storage::Entry.record \ account: @account, @@ -99,7 +99,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! enqueues MaterializeJob for board when board_id present" do + test "record enqueues MaterializeJob for board when board_id present" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do Storage::Entry.record \ account: @account, @@ -110,7 +110,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! does not enqueue job when account is deleted" do + test "record does not enqueue job when account is deleted" do # The graceful handling is that find_by returns nil, so no job is enqueued # for a non-existent account. We can't test with a fake ID due to FK constraints, # but we can verify the find_by behavior by stubbing. @@ -124,7 +124,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! does not enqueue board job when board is deleted" do + test "record does not enqueue board job when board is deleted" do Board.stubs(:find_by).returns(nil) # Account job still enqueued, but board job is not From 82626f020d9054a9c2c783aaa52b2382c1f91733 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 13 Dec 2025 09:29:50 -0800 Subject: [PATCH 137/318] Tailscale serve support (#2126) Ensure we can serve the app from multiple hosts without breaking links. * Switch unnecessary full URLs to paths * Drop default host/port URL options for controllers Shell 1 ```bash bin/dev ``` Shell 2 ```bash tailscale serve http://fizzy.localhost:3006 ``` --- app/controllers/concerns/authentication.rb | 4 ++-- app/controllers/concerns/authorization.rb | 2 +- config/environments/development.rb | 13 +++++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 08129d9c7..deb8b3fa3 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -38,7 +38,7 @@ module Authentication def require_account unless Current.account.present? - redirect_to main_app.session_menu_url(script_name: nil) + redirect_to main_app.session_menu_path(script_name: nil) end end @@ -125,7 +125,7 @@ module Authentication serve_development_magic_link(magic_link) session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link session[:return_to_after_authenticating] = return_to if return_to - redirect_to main_app.session_magic_link_url(script_name: nil) + redirect_to main_app.session_magic_link_path(script_name: nil) end def serve_development_magic_link(magic_link) diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 46769992e..1afb878ef 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -26,7 +26,7 @@ module Authorization end def ensure_can_access_account - redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? + redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active? end def redirect_existing_user diff --git a/config/environments/development.rb b/config/environments/development.rb index 4e6285263..e40d4fe16 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -85,9 +85,14 @@ Rails.application.configure do config.action_mailer.raise_delivery_errors = false end - config.hosts = %w[ fizzy.localhost localhost 127.0.0.1 ] + [ /^fizzy-\d+(:\d+)$/ ] + config.hosts = [ + "fizzy.localhost", + "localhost", + "127.0.0.1", + /^fizzy-\d+(:\d+)?$/, # review apps: fizzy-123:3000 + /\.ts\.net$/ # tailscale serve: hostname.tail1234.ts.net + ] - # Set host to be used by links generated in mailer and notification view templates. - config.action_controller.default_url_options = { host: config.hosts.first, port: 3006 } - config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 } + # Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs) + config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" } end From db4c8c113861658677cf5059b0d6aa3de5c4fe5f Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 13 Dec 2025 12:37:57 -0500 Subject: [PATCH 138/318] Introduce Vips configuration --- config/initializers/vips.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 config/initializers/vips.rb diff --git a/config/initializers/vips.rb b/config/initializers/vips.rb new file mode 100644 index 000000000..c965f3734 --- /dev/null +++ b/config/initializers/vips.rb @@ -0,0 +1,14 @@ +# Disable Openslide to prevent sqlite segfault in forked parallel workers +# Requires libvips 8.13+ +Vips.block "VipsForeignLoadOpenslide", true if Vips.respond_to?(:block) + +# Limit libvips to 4 threads for each thread pool. Default is #CPUs. +Vips.concurrency_set 4 + +# Limit libvips caches to reduce memory pressure. +# +# Do not disable entirely since libvips relies on some caching internally. +# (When we disabled caches, we hit a ton of JPEG out of order read errors.) +Vips.cache_set_max 10 # Default 100 +Vips.cache_set_max_mem 10.megabytes # Default 100MB +Vips.cache_set_max_files 10 # Default 100 From 139bf3cf81dda1f8836a5d0421f9cfd3f1a04699 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 13 Dec 2025 13:05:30 -0500 Subject: [PATCH 139/318] Validate avatar sizes --- app/models/user.rb | 2 -- app/models/user/avatar.rb | 26 +++++++++++++++++++++-- test/controllers/users_controller_test.rb | 14 +++++++++++- test/models/user/avatar_test.rb | 23 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 20ee1c40c..6503881a3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,8 +14,6 @@ class User < ApplicationRecord has_many :pinned_cards, through: :pins, source: :card has_many :exports, class_name: "Account::Export", dependent: :destroy - scope :with_avatars, -> { preload(:account, :avatar_attachment) } - def deactivate transaction do accesses.destroy_all diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index c635edddb..400738cb1 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -2,13 +2,20 @@ module User::Avatar extend ActiveSupport::Concern ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze + MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze included do has_one_attached :avatar do |attachable| attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately end - validate :avatar_content_type_allowed + scope :with_avatars, -> { preload(:account, :avatar_attachment) } + + validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached? + end + + def avatar_attached? + avatar.attached? end def avatar_thumbnail @@ -17,8 +24,23 @@ module User::Avatar private def avatar_content_type_allowed - if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) + if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image") end end + + def avatar_dimensions_allowed + return unless avatar.blob.analyzed? || avatar.blob.analyze + + width = avatar.blob.metadata[:width] + height = avatar.blob.metadata[:height] + + if width && width > MAX_AVATAR_DIMENSIONS[:width] + errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px") + end + + if height && height > MAX_AVATAR_DIMENSIONS[:height] + errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px") + end + end end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 20008dcee..8e99e4cef 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -65,7 +65,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end - test "update with invalid avatar shows validation error" do + test "update with invalid avatar content type shows validation error" do sign_in_as :kevin svg_file = fixture_file_upload("avatar.svg", "image/svg+xml") @@ -76,6 +76,18 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/ end + test "update with oversized avatar shows validation error" do + sign_in_as :kevin + + png_file = fixture_file_upload("avatar.png", "image/png") + + ActiveStorage::Analyzer::ImageAnalyzer::Vips.any_instance.stubs(:metadata).returns({ width: 5000, height: 100 }) + + put user_path(users(:kevin)), params: { user: { avatar: png_file } } + assert_response :unprocessable_entity + assert_select ".txt-negative", text: /width must be less than 4096px/ + end + test "update with valid avatar" do sign_in_as :kevin diff --git a/test/models/user/avatar_test.rb b/test/models/user/avatar_test.rb index ed502d7ab..fcaf05d0b 100644 --- a/test/models/user/avatar_test.rb +++ b/test/models/user/avatar_test.rb @@ -33,4 +33,27 @@ class User::AvatarTest < ActiveSupport::TestCase assert users(:david).avatar.variant(:thumb).processed? end + + test "rejects images that are too wide" do + users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") + users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 5000, height: 100 }) + + assert_not users(:david).valid? + assert_includes users(:david).errors[:avatar], "width must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:width]}px" + end + + test "rejects images that are too tall" do + users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") + users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 100, height: 5000 }) + + assert_not users(:david).valid? + assert_includes users(:david).errors[:avatar], "height must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:height]}px" + end + + test "accepts images within dimension limits" do + users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") + users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 4096, height: 4096 }) + + assert users(:david).valid? + end end From fbc586646f25952106eee71c7b0c35ff85f23daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Matos?= Date: Sat, 13 Dec 2025 15:10:13 -0300 Subject: [PATCH 140/318] Refactor: improve query scope composition with merge syntax (#2131) * Refactor: improve query scope composition with merge syntax Replace manual WHERE clause concatenation with Rails' merge method for more elegant and maintainable scope composition across Card, Comment, and Filter models. This approach better follows Rails conventions and improves code readability. * Extend scope composition improvements to Card::Closeable Apply the same nested hash syntax pattern to closures table references in order and where clauses. * Remove unnecessary outer braces from where clause --------- Co-authored-by: Jeremy Daer --- app/models/card/closeable.rb | 6 +++--- app/models/card/stallable.rb | 2 +- app/models/comment.rb | 4 ++-- app/models/filter.rb | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb index 51852c9a5..e4cea509f 100644 --- a/app/models/card/closeable.rb +++ b/app/models/card/closeable.rb @@ -7,9 +7,9 @@ module Card::Closeable scope :closed, -> { joins(:closure) } scope :open, -> { where.missing(:closure) } - scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) } - scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) } - scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) } + scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) } + scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) } + scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) } end def closed? diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb index 545cc87ee..03348dfde 100644 --- a/app/models/card/stallable.rb +++ b/app/models/card/stallable.rb @@ -7,7 +7,7 @@ module Card::Stallable has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy scope :with_activity_spikes, -> { joins(:activity_spike) } - scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } + scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } before_update :remember_to_detect_activity_spikes after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes? diff --git a/app/models/comment.rb b/app/models/comment.rb index a396a904f..d0315eea8 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -10,8 +10,8 @@ class Comment < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :desc } scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) } - scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) } - scope :by_user, -> { joins(:creator).where.not(creator: { role: "system" }) } + scope :by_system, -> { joins(:creator).where(creator: { role: :system }) } + scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) } after_create_commit :watch_card_by_creator diff --git a/app/models/filter.rb b/app/models/filter.rb index 9dac977bd..9bfefe176 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -29,7 +29,7 @@ class Filter < ApplicationRecord result = result.where(creator_id: creators.ids) if creators.present? result = result.where(board: boards.ids) if boards.present? result = result.tagged_with(tags.ids) if tags.present? - result = result.where("cards.created_at": creation_window) if creation_window + result = result.where(cards: { created_at: creation_window }) if creation_window result = result.closed_at_window(closure_window) if closure_window result = result.closed_by(closers) if closers.present? result = terms.reduce(result) do |result, term| From 6050b80fdd54734d0ad6b643190a686201f23627 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 13 Dec 2025 10:32:18 -0800 Subject: [PATCH 141/318] Reduce retained containers to free disk space faster (#32) Beta/staging: 1 (down from default 5) Production: 2 (registry is local, quick to pull) --- saas/config/deploy.beta.yml | 2 ++ saas/config/deploy.production.yml | 2 ++ saas/config/deploy.staging.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index c726c10b7..8744705b4 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -1,3 +1,5 @@ +retain_containers: 1 + servers: web: hosts: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index 1e66a4e96..d8e4dc4b9 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -1,3 +1,5 @@ +retain_containers: 2 + servers: web: hosts: diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index 8dd91a092..2abc18ecb 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -1,3 +1,5 @@ +retain_containers: 1 + servers: web: hosts: From 206610900317710547d05c36b1f2281b2d0f385a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Matos?= Date: Sat, 13 Dec 2025 15:34:35 -0300 Subject: [PATCH 142/318] Add test coverage for with_golden_first scope (#2130) * Add test coverage for with_golden_first scope Test verifies that the with_golden_first scope correctly orders golden cards before non-golden cards in query results. * Refactor golden test: use instance variables, add subordering coverage Extract @golden and @non_golden fixtures into setup for reuse across all tests. Simplify with_golden_first test to verify both primary ordering (golden before non-golden) and subordering preservation. --------- Co-authored-by: Jeremy Daer --- test/models/card/golden_test.rb | 37 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/test/models/card/golden_test.rb b/test/models/card/golden_test.rb index 3ddd8ea39..6a0495fbe 100644 --- a/test/models/card/golden_test.rb +++ b/test/models/card/golden_test.rb @@ -3,40 +3,51 @@ require "test_helper" class Card::GoldenTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) + @golden, @non_golden = cards(:logo), cards(:text) end test "check whether a card is golden" do - assert cards(:logo).golden? - assert_not cards(:text).golden? + assert @golden.golden? + assert_not @non_golden.golden? end test "promote and demote from golden" do - assert_changes -> { cards(:text).reload.golden? }, to: true do - cards(:text).gild + assert_changes -> { @non_golden.reload.golden? }, to: true do + @non_golden.gild end - assert_changes -> { cards(:logo).reload.golden? }, to: false do - cards(:logo).ungild + assert_changes -> { @golden.reload.golden? }, to: false do + @golden.ungild end end test "scopes" do - assert_includes Card.golden, cards(:logo) - assert_not_includes Card.golden, cards(:text) + assert_includes Card.golden, @golden + assert_not_includes Card.golden, @non_golden + end + + test "with_golden_first orders golden first" do + ordered = Card.where(id: [ @golden.id, @non_golden.id ]).with_golden_first + assert_equal [ @golden, @non_golden ], ordered.to_a + + # Preserves base ordering as subordering + @non_golden.gild + base_ordered = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).to_a + with_golden = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).with_golden_first.to_a + assert_equal base_ordered, with_golden end test "gilding a card touches both the card and the board" do - card = cards(:text) - board = card.board + board = @non_golden.board - card_updated_at = card.updated_at + card_updated_at = @non_golden.updated_at board_updated_at = board.updated_at travel 1.minute do - card.gild + @non_golden.gild end - assert card.reload.updated_at > card_updated_at + assert @non_golden.reload.updated_at > card_updated_at assert board.reload.updated_at > board_updated_at end end From 3d3593c8f651dbe808a9f56308788df7125740ca Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 13 Dec 2025 10:39:01 -0800 Subject: [PATCH 143/318] Bump fizzy-saas to retain fewer docker images (#2134) References https://github.com/basecamp/fizzy-saas/pull/32 --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 070d23a72..3d12fc6c7 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 210e68d7092a78c892c6a14df59c2167de0682b1 + revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143 specs: fizzy-saas (0.1.0) audits1984 From 472dbeee8c53e2e4b96b960756047f59672eb652 Mon Sep 17 00:00:00 2001 From: nu-wa <32520539+nu-wa@users.noreply.github.com> Date: Sat, 13 Dec 2025 19:57:08 +0100 Subject: [PATCH 144/318] SMTP: support SMTPS on port 465 (#2132) * Add more configuration options for the SMTP connection * Add SMTP_TLS option for implicit TLS connections For SMTPS servers (typically port 465), set SMTP_TLS=true. Port auto-defaults to 465 when TLS is enabled, 587 otherwise. STARTTLS is used by default and automatically disabled when TLS is on. Fixes boolean conversion bug in original PR (string "false" is truthy) and removes insecure default for certificate verification. --------- Co-authored-by: Jeremy Daer --- config/environments/production.rb | 5 +++-- docs/docker-deployment.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 031c9d2cb..173318fe3 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -11,12 +11,13 @@ Rails.application.configure do config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { address: smtp_address, - port: ENV.fetch("SMTP_PORT", "587").to_i, + port: ENV.fetch("SMTP_PORT", ENV["SMTP_TLS"] == "true" ? "465" : "587").to_i, domain: ENV.fetch("SMTP_DOMAIN", nil), user_name: ENV.fetch("SMTP_USERNAME", nil), password: ENV.fetch("SMTP_PASSWORD", nil), authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"), - enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true" + tls: ENV["SMTP_TLS"] == "true", + openssl_verify_mode: ENV["SMTP_SSL_VERIFY_MODE"] } end diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index 15ec58367..f92b80ca2 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -78,14 +78,15 @@ You can then plug all your SMTP settings from that provider into Fizzy via the f - `MAILER_FROM_ADDRESS` - the "from" address that Fizzy should use to send email - `SMTP_ADDRESS` - the address of the SMTP server you'll send through -- `SMTP_PORT` - the port number you use to connect to that SMTP server (the default is 587) +- `SMTP_PORT` - the port number (defaults to 465 when `SMTP_TLS` is set, 587 otherwise) - `SMTP_USERNAME`/`SMTP_PASSWORD` - the credentials for logging in to the SMTP server Less commonly, you might also need to set some of the following: +- `SMTP_TLS` - set to `true` only for servers requiring implicit TLS (SMTPS on port 465); STARTTLS is used automatically by default so most servers don't need this - `SMTP_DOMAIN` - the domain name advertised to the server when connecting - `SMTP_AUTHENTICATION` - if you need an authentication method other than the default `plain` -- `SMTP_ENABLE_STARTTLS_AUTO` - if you need to disable TLS on the SMTP connection +- `SMTP_SSL_VERIFY_MODE` - set to `none` to skip certificate verification (for self-signed certs) You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration). From 5ade7e1a52a0a5ada28bb08928e37cc31eaf80f2 Mon Sep 17 00:00:00 2001 From: Italo Matos Date: Sat, 13 Dec 2025 16:31:48 -0300 Subject: [PATCH 145/318] Refactor: Simplify TimeWindowParser using Rails convenience methods - Replace `beginning_of_day..end_of_day` with `all_day` - Replace `beginning_of_week..end_of_week` with `all_week` - Replace `beginning_of_month..end_of_month` with `all_month` - Replace `beginning_of_year..end_of_year` with `all_year` These changes improve code readability by using idiomatic Rails methods that accomplish the same thing in a more concise and expressive way. --- app/models/time_window_parser.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/time_window_parser.rb b/app/models/time_window_parser.rb index a8da2a580..cdfc13c5f 100644 --- a/app/models/time_window_parser.rb +++ b/app/models/time_window_parser.rb @@ -31,21 +31,21 @@ class TimeWindowParser def parse(string) case normalize(string) when "today" - now.beginning_of_day..now.end_of_day + now.all_day when "yesterday" - (now - 1.day).beginning_of_day..(now - 1.day).end_of_day + (now - 1.day).all_day when "thisweek" - now.beginning_of_week..now.end_of_week + now.all_week when "thismonth" - now.beginning_of_month..now.end_of_month + now.all_month when "thisyear" - now.beginning_of_year..now.end_of_year + now.all_year when "lastweek" - (now - 1.week).beginning_of_week..(now - 1.week).end_of_week + (now - 1.week).all_week when "lastmonth" - (now - 1.month).beginning_of_month..(now - 1.month).end_of_month + (now - 1.month).all_month when "lastyear" - (now - 1.year).beginning_of_year..(now - 1.year).end_of_year + (now - 1.year).all_year end end From f3bd38ea79ba1bdeac2c7a30a8cab595833dc7ca Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Sat, 13 Dec 2025 22:10:21 +0200 Subject: [PATCH 146/318] Add tests for tenancy middleware and timezone cookie --- .../concerns/current_timezone_test.rb | 17 +++++ .../middleware/account_slug_extractor_test.rb | 65 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 test/controllers/concerns/current_timezone_test.rb create mode 100644 test/middleware/account_slug_extractor_test.rb diff --git a/test/controllers/concerns/current_timezone_test.rb b/test/controllers/concerns/current_timezone_test.rb new file mode 100644 index 000000000..4f0d583fe --- /dev/null +++ b/test/controllers/concerns/current_timezone_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class CurrentTimezoneTest < ActionDispatch::IntegrationTest + test "includes the timezone cookie in the ETag" do + cookies[:timezone] = "America/New_York" + get user_avatar_path(users(:kevin)) + etag = response.headers.fetch("ETag") + + get user_avatar_path(users(:kevin)), headers: { "If-None-Match" => etag } + assert_equal 304, response.status + + cookies[:timezone] = "America/Los_Angeles" + get user_avatar_path(users(:kevin)), headers: { "If-None-Match" => etag } + assert_response :success + assert_not_equal etag, response.headers.fetch("ETag") + end +end diff --git a/test/middleware/account_slug_extractor_test.rb b/test/middleware/account_slug_extractor_test.rb new file mode 100644 index 000000000..79a1c6ab9 --- /dev/null +++ b/test/middleware/account_slug_extractor_test.rb @@ -0,0 +1,65 @@ +require "test_helper" +require "rack/mock" + +class AccountSlugExtractorTest < ActiveSupport::TestCase + test "moves account prefix from PATH_INFO to SCRIPT_NAME" do + account = accounts(:initech) + slug = AccountSlug.encode(account.external_account_id) + + captured = call_with_env "/#{slug}/boards" + + assert_equal "/#{slug}", captured.fetch(:script_name) + assert_equal "/boards", captured.fetch(:path_info) + assert_equal account.external_account_id, captured.fetch(:external_account_id) + assert_equal account, captured.fetch(:current_account) + end + + test "treats a bare account prefix as the root path" do + account = accounts(:initech) + slug = AccountSlug.encode(account.external_account_id) + + captured = call_with_env "/#{slug}" + + assert_equal "/#{slug}", captured.fetch(:script_name) + assert_equal "/", captured.fetch(:path_info) + end + + test "detects the account prefix when already in SCRIPT_NAME" do + account = accounts(:initech) + slug = AccountSlug.encode(account.external_account_id) + + captured = call_with_env "/boards", "SCRIPT_NAME" => "/#{slug}" + + assert_equal "/#{slug}", captured.fetch(:script_name) + assert_equal "/boards", captured.fetch(:path_info) + assert_equal account, captured.fetch(:current_account) + end + + test "clears Current.account when no account prefix is present" do + captured = call_with_env "/boards" + + assert_equal "", captured.fetch(:script_name) + assert_equal "/boards", captured.fetch(:path_info) + assert_nil captured.fetch(:external_account_id) + assert_nil captured.fetch(:current_account) + end + + private + def call_with_env(path, extra_env = {}) + captured = {} + extra_env = { "action_dispatch.routes" => Rails.application.routes }.merge(extra_env) + + app = ->(env) do + captured[:script_name] = env["SCRIPT_NAME"] + captured[:path_info] = env["PATH_INFO"] + captured[:external_account_id] = env["fizzy.external_account_id"] + captured[:current_account] = Current.account + [ 200, {}, [ "ok" ] ] + end + + middleware = AccountSlug::Extractor.new(app) + middleware.call Rack::MockRequest.env_for(path, extra_env.merge(method: "GET")) + + captured + end +end From 51df4af6ba33d1e7aca104c21278aea63c272233 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 13 Dec 2025 16:00:54 -0500 Subject: [PATCH 147/318] Add test coverage for the image lightbox because we're about to mess with it. --- test/system/smoke_test.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/system/smoke_test.rb b/test/system/smoke_test.rb index 14c7a7116..c307c50d2 100644 --- a/test/system/smoke_test.rb +++ b/test/system/smoke_test.rb @@ -54,6 +54,14 @@ class SmokeTest < ApplicationSystemTestCase assert_selector "a img[src*='/rails/active_storage']" assert_selector "figcaption span.attachment__name", text: "moon.jpg" end + + # Click the image to open the lightbox + find("action-text-attachment figure.attachment a:has(img)").click + + assert_selector "dialog.lightbox[open]" + within("dialog.lightbox") do + assert_selector "img.lightbox__image[src*='/rails/active_storage']" + end end test "dismissing notifications" do From e9cb2956ee004f418b342044493f3a1170fa3aa6 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 13 Dec 2025 16:45:59 -0500 Subject: [PATCH 148/318] Lightbox uses Stimulus target callbacks instead of data-action Remove data-action from the sanitizer allowlist to disallow injection of potentially malicious Stimulus actions in user-provided content. The lightbox controller now uses imageTarget callbacks to handle clicks on image links. Also add the file name as a caption in the light box, and fix the caption color for dark mode visibility. --- app/assets/stylesheets/lightbox.css | 2 +- .../controllers/lightbox_controller.js | 17 +++++++++++++++-- .../blobs/web/_representation.html.erb | 2 +- .../cards/display/perma/_background.html.erb | 2 +- config/initializers/sanitization.rb | 2 +- test/helpers/action_text_rendering_test.rb | 15 +++++++++++++++ 6 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 test/helpers/action_text_rendering_test.rb diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/lightbox.css index 4a9cb3d8b..65144a22b 100644 --- a/app/assets/stylesheets/lightbox.css +++ b/app/assets/stylesheets/lightbox.css @@ -67,7 +67,7 @@ } .lightbox__caption { - color: var(--color-ink-inverted); + color: var(--color-white); &:empty { display: none; diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js index e8bc48f03..fc86b18f7 100644 --- a/app/javascript/controllers/lightbox_controller.js +++ b/app/javascript/controllers/lightbox_controller.js @@ -3,9 +3,22 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "caption", "image", "dialog", "zoomedImage" ] - open(event) { + imageTargetConnected(element) { + element.addEventListener("click", this.#handleImageClick) + } + + imageTargetDisconnected(element) { + element.removeEventListener("click", this.#handleImageClick) + } + + #handleImageClick = (event) => { + event.preventDefault() + this.#open(event.currentTarget) + } + + #open(link) { this.dialogTarget.showModal() - this.#set(event.target.closest("a")) + this.#set(link) } // Wait for the transition to finish before resetting the image diff --git a/app/views/active_storage/blobs/web/_representation.html.erb b/app/views/active_storage/blobs/web/_representation.html.erb index 2aa1b51a4..ca934f469 100644 --- a/app/views/active_storage/blobs/web/_representation.html.erb +++ b/app/views/active_storage/blobs/web/_representation.html.erb @@ -15,7 +15,7 @@ <% elsif blob.variable? %> - <%= link_to url_for(blob.variant(variant)), data: { action: "lightbox#open:prevent", lightbox_target: "image" } do %> + <%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> <%= image_tag url_for(blob.variant(variant)), width: width, height: height %> <% end %> <% elsif blob.previewable? %> diff --git a/app/views/cards/display/perma/_background.html.erb b/app/views/cards/display/perma/_background.html.erb index 6ade6f1b5..2315b0118 100644 --- a/app/views/cards/display/perma/_background.html.erb +++ b/app/views/cards/display/perma/_background.html.erb @@ -1,5 +1,5 @@ <%= render "cards/display/common/background", card: card do %> - <%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", action: "lightbox#open:prevent", lightbox_target: "image" } do %> + <%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", lightbox_target: "image" } do %> <%= icon_tag "picture-zoom" %> Zoom background image <% end %> diff --git a/config/initializers/sanitization.rb b/config/initializers/sanitization.rb index 6fae14b25..0f2325eff 100644 --- a/config/initializers/sanitization.rb +++ b/config/initializers/sanitization.rb @@ -1,6 +1,6 @@ Rails.application.config.after_initialize do Rails::HTML5::SafeListSanitizer.allowed_tags.merge(%w[ s table tr td th thead tbody details summary video source ]) - Rails::HTML5::SafeListSanitizer.allowed_attributes.merge(%w[ data-turbo-frame controls type width data-action data-lightbox-target data-lightbox-url-value ]) + Rails::HTML5::SafeListSanitizer.allowed_attributes.merge(%w[ data-turbo-frame data-lightbox-target data-lightbox-caption-value controls type width ]) # ugh, see https://github.com/rails/rails/issues/54478 which I need to fix upstream --mike ActionText::ContentHelper.allowed_tags = Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + [ ActionText::Attachment.tag_name, "figure", "figcaption" ] + ActionText::ContentHelper.allowed_tags.to_a diff --git a/test/helpers/action_text_rendering_test.rb b/test/helpers/action_text_rendering_test.rb new file mode 100644 index 000000000..6cb8fcc94 --- /dev/null +++ b/test/helpers/action_text_rendering_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class ActionTextRenderingTest < ActionView::TestCase + test "data-action attributes in user content are stripped" do + malicious_html = <<~HTML +

Click here: malicious link

+ HTML + + content = ActionText::Content.new(malicious_html) + rendered = content.to_s + + assert_no_match(/data-action/, rendered) + assert_match(/malicious link<\/a>/, rendered) + end +end From 90a48e3119897f28f81c0d50ef68e167fccd49f6 Mon Sep 17 00:00:00 2001 From: hiendinhngoc Date: Sun, 14 Dec 2025 14:24:53 +0700 Subject: [PATCH 149/318] Fix unexpected remove empty line from README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 41b78dcd1..867c2dba7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ You can find the details of how to do a Docker-based deployment in our [Docker d If you want more flexibility to customize your Fizzy installation by changing its code, and deploy those changes to your server, then we recommend you deploy Fizzy with Kamal. You can find a complete walkthrough of doing that in our [Kamal deployment guide](docs/kamal-deployment.md). + ## Development You are welcome -- and encouraged -- to modify Fizzy to your liking. From cd4fbc011ca4df8ce6cdad41dee47105d4149580 Mon Sep 17 00:00:00 2001 From: Italo Matos Date: Sun, 14 Dec 2025 14:22:06 -0300 Subject: [PATCH 150/318] Refactor: Replace reverse_merge with with_defaults for improved readability Replace all occurrences of reverse_merge with with_defaults across the codebase. The with_defaults method provides clearer intent and better readability when setting default values for hash parameters. Changes: - app/helpers/columns_helper.rb: Update column_frame_tag method - app/models/user/email_address_changeable.rb: Update generate_email_address_change_token method - app/models/account.rb: Update create_with_owner method - app/controllers/concerns/filter_scoped.rb: Update filter_params method This refactoring maintains the same functionality while improving code clarity. --- app/controllers/concerns/filter_scoped.rb | 2 +- app/helpers/columns_helper.rb | 2 +- app/models/account.rb | 2 +- app/models/user/email_address_changeable.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/concerns/filter_scoped.rb b/app/controllers/concerns/filter_scoped.rb index a9341f604..5507c9b8d 100644 --- a/app/controllers/concerns/filter_scoped.rb +++ b/app/controllers/concerns/filter_scoped.rb @@ -16,7 +16,7 @@ module FilterScoped end def filter_params - params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) + params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) end def set_user_filtering diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 222d41357..f48b5d557 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -43,7 +43,7 @@ module ColumnsHelper end def column_frame_tag(id, src: nil, data: {}, **options, &block) - data = data.reverse_merge \ + data = data.with_defaults \ drag_and_drop_refresh: true, controller: "frame", action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload" diff --git a/app/models/account.rb b/app/models/account.rb index df0b845b0..f9b2589c0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -19,7 +19,7 @@ class Account < ApplicationRecord def create_with_owner(account:, owner:) create!(**account).tap do |account| account.users.create!(role: :system, name: "System") - account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current)) + account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current)) end end end diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb index 2c8de7217..79479f788 100644 --- a/app/models/user/email_address_changeable.rb +++ b/app/models/user/email_address_changeable.rb @@ -39,7 +39,7 @@ module User::EmailAddressChangeable private def generate_email_address_change_token(from: identity.email_address, to:, **options) - options = options.reverse_merge( + options = options.with_defaults( for: EMAIL_CHANGE_TOKEN_PURPOSE, old_email_address: from, new_email_address: to, From 2e66fdff4a16380af010976896493ee166d8f0b7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 14 Dec 2025 18:53:13 +0100 Subject: [PATCH 151/318] Update lexxy to bring fixes from https://github.com/basecamp/lexxy/releases/tag/v0.1.24.beta --- Gemfile.lock | 4 ++-- Gemfile.saas.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3a9430a61..5a1eeb3cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 specs: - lexxy (0.1.23.beta) + lexxy (0.1.24.beta) rails (>= 8.0.2) GIT diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 3d12fc6c7..e4a604416 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -43,9 +43,9 @@ GIT GIT remote: https://github.com/basecamp/lexxy - revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 specs: - lexxy (0.1.23.beta) + lexxy (0.1.24.beta) rails (>= 8.0.2) GIT From c6725fdca005ad70005f79654fd01d77af95ba37 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 14 Dec 2025 13:04:19 -0500 Subject: [PATCH 152/318] Bust comment view cache because, unlike ordinary view file changes, the change to Action Text attachment rendering in e9cb2956 doesn't bust the cache automatically. --- app/views/cards/comments/_comment.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 49ae74182..fee6d6d25 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -1,6 +1,6 @@ <% cache comment do %> <%= turbo_frame_tag comment, :container do %> - <%# Bump for CSS changes: 2025-06-30 -%> + <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%>
">
+ +<%= render "account/settings/subscription_panel" if Fizzy.saas? %> diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb deleted file mode 100644 index 7c7491d2a..000000000 --- a/app/views/admin/stats/show.html.erb +++ /dev/null @@ -1,126 +0,0 @@ -<% @page_title = "Account Statistics" %> - -<% content_for :header do %> -

<%= @page_title %>

-<% end %> - -
-
-
-
-
-

Accounts Created

-
-
-
-
Total
-
- <%= @accounts_total %> -
-
-
-
7 days
-
- <%= @accounts_last_7_days %> -
-
-
-
24 hours
-
- <%= @accounts_last_24_hours %> -
-
-
-
- -
-
-

Identities Created

-
-
-
-
Total
-
- <%= @identities_total %> -
-
-
-
7 days
-
- <%= @identities_last_7_days %> -
-
-
-
24 hours
-
- <%= @identities_last_24_hours %> -
-
-
-
-
-
- -
-
-

- 10 Most Recent Signups -

-
- -
    - <% @recent_accounts.each do |account| %> - <% admin_user = account.users.owner.first %> -
  • -
    - <%= account.name %> -
    - - #<%= account.external_account_id %> • - <%= admin_user&.identity&.email_address || "No admin" %> - -
    - -
    - <%= time_ago_in_words(account.created_at) %> ago -
    -
  • - <% end %> -
-
- -
-
-

- Top 20 Accounts by Card Count -

-
- -
    - <% @top_accounts.each do |account| %> - <% admin_user = account.users.owner.first %> -
  • -
    - <%= account.name %> -
    - - #<%= account.external_account_id %> • - <%= admin_user&.identity&.email_address || "No admin" %> - -
    - -
    - <%= number_with_delimiter(account.cards_count) %> - cards -
    -
  • - <% end %> -
-
-
diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index 983f359ed..c85236683 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -1,5 +1,5 @@ -<% cache card do %> -
+
+ <% cache card do %>
<%= render "cards/container/gild", card: card if card.published? && !card.closed? %> <%= render "cards/container/image", card: card %> @@ -33,11 +33,15 @@ <%= render "cards/display/preview/bubble", card: card %> <% end %>
+ <% end %> - <% if card.published? %> - <%= render "cards/container/footer/published", card: card %> - <% elsif card.drafted? %> - <%= render "cards/container/footer/draft", card: card %> + <% if card.published? %> + <%= render "cards/container/footer/published", card: card %> + <% elsif card.drafted? %> + <% if Fizzy.saas? %> + <%= render "cards/container/footer/saas/create", card: card %> + <% else %> + <%= render "cards/container/footer/create", card: card %> <% end %> -
-<% end %> + <% end %> +
diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb new file mode 100644 index 000000000..3e89952df --- /dev/null +++ b/app/views/cards/container/footer/_create.html.erb @@ -0,0 +1,19 @@ +
+
+ <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", + title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", + form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> + Create card + <% end %> + + <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", + title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> + Create and add another + <% end %> +
+ + <%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %> +
+ diff --git a/app/views/cards/container/footer/_draft.html.erb b/app/views/cards/container/footer/_draft.html.erb deleted file mode 100644 index 0dc6061a5..000000000 --- a/app/views/cards/container/footer/_draft.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -
- <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", - title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", - form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> - Create card - <% end %> - - <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", - title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> - Create and add another - <% end %> -
diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 0c8dbdd73..61b19713c 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -17,6 +17,7 @@ <%= render "layouts/theme_preference" %> <%= stylesheet_link_tags %> + <%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %> <%= javascript_importmap_tags %> <%= tenanted_action_cable_meta_tag %> diff --git a/config/routes.rb b/config/routes.rb index aa2815433..d84fa06e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,9 +2,9 @@ Rails.application.routes.draw do root "events#index" namespace :account do + resource :entropy resource :join_code resource :settings - resource :entropy resources :exports, only: [ :create, :show ] end @@ -239,6 +239,5 @@ Rails.application.routes.draw do namespace :admin do mount MissionControl::Jobs::Engine, at: "/jobs" - get "stats", to: "stats#show" end end diff --git a/test/controllers/admin/stats_controller_test.rb b/test/controllers/admin/stats_controller_test.rb deleted file mode 100644 index f87dd7cf4..000000000 --- a/test/controllers/admin/stats_controller_test.rb +++ /dev/null @@ -1,23 +0,0 @@ -require "test_helper" - -class Admin::StatsControllerTest < ActionDispatch::IntegrationTest - test "staff can access stats" do - sign_in_as :david - - untenanted do - get admin_stats_url - end - - assert_response :success - end - - test "non-staff cannot access stats" do - sign_in_as :jz - - untenanted do - get admin_stats_url - end - - assert_response :forbidden - end -end diff --git a/test/fixtures/accesses.yml b/test/fixtures/accesses.yml index 25800f47b..2fecdcce3 100644 --- a/test/fixtures/accesses.yml +++ b/test/fixtures/accesses.yml @@ -21,3 +21,9 @@ private_kevin: account: 37s_uuid board: private_uuid user: kevin_uuid + +miltons_wish_list_mike: + id: <%= ActiveRecord::FixtureSet.identify("miltons_wish_list_mike", :uuid) %> + account: initech_uuid + board: miltons_wish_list_uuid + user: mike_uuid diff --git a/test/fixtures/cards.yml b/test/fixtures/cards.yml index 5bfa77e6d..4785ee8d1 100644 --- a/test/fixtures/cards.yml +++ b/test/fixtures/cards.yml @@ -79,3 +79,14 @@ paycheck: status: published last_active_at: <%= 1.week.ago %> account: initech_uuid + +unfinished_thoughts: + id: <%= ActiveRecord::FixtureSet.identify("unfinished_thoughts", :uuid) %> + number: 3 + board: miltons_wish_list_uuid + creator: mike_uuid + title: Some unfinished thoughts + created_at: <%= 1.week.ago %> + status: drafted + last_active_at: <%= 1.week.ago %> + account: initech_uuid diff --git a/test/models/card_test.rb b/test/models/card_test.rb index e18ce43f8..9c7d45add 100644 --- a/test/models/card_test.rb +++ b/test/models/card_test.rb @@ -81,7 +81,7 @@ class CardTest < ActiveSupport::TestCase test "open" do assert_equal cards(:logo, :layout, :text, :buy_domain).to_set, accounts("37s").cards.open.to_set - assert_equal cards(:radio, :paycheck).to_set, accounts("initech").cards.open.to_set + assert_equal cards(:radio, :paycheck, :unfinished_thoughts).to_set, accounts("initech").cards.open.to_set end test "card_unassigned" do From 0278fe6ae4209882a349d80203cd0e190128ff0f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 17:20:18 +0100 Subject: [PATCH 206/318] Revert "Mobile app / Scoped stylesheets" (#1698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 39c1906e67c2759da25911a7eeada6699ce3a969. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/assets/stylesheets/{web => }/_global.css | 2 +- app/assets/stylesheets/android/utilities.css | 5 - .../stylesheets/{web => }/animation.css | 0 .../stylesheets/{web => }/autoresize.css | 0 app/assets/stylesheets/{web => }/avatars.css | 0 app/assets/stylesheets/{web => }/bar.css | 0 app/assets/stylesheets/{web => }/base.css | 0 app/assets/stylesheets/{web => }/bubble.css | 0 app/assets/stylesheets/{web => }/buttons.css | 0 .../stylesheets/{web => }/card-columns.css | 0 .../stylesheets/{web => }/card-perma.css | 0 app/assets/stylesheets/{web => }/cards.css | 0 .../stylesheets/{web => }/circled-text.css | 0 .../stylesheets/{web => }/color-picker.css | 0 app/assets/stylesheets/{web => }/comments.css | 0 app/assets/stylesheets/{web => }/dialog.css | 0 app/assets/stylesheets/{web => }/dividers.css | 0 .../stylesheets/{web => }/drag_and_drop.css | 0 app/assets/stylesheets/{web => }/events.css | 1 - app/assets/stylesheets/{web => }/filters.css | 0 app/assets/stylesheets/{web => }/flash.css | 0 .../stylesheets/{web => }/golden-effect.css | 0 app/assets/stylesheets/{web => }/header.css | 11 +- app/assets/stylesheets/{web => }/inputs.css | 0 app/assets/stylesheets/ios/utilities.css | 5 - app/assets/stylesheets/{web => }/knobs.css | 0 app/assets/stylesheets/{web => }/layout.css | 0 app/assets/stylesheets/{web => }/lexxy.css | 0 app/assets/stylesheets/{web => }/lightbox.css | 0 app/assets/stylesheets/{web => }/markdown.css | 2 +- app/assets/stylesheets/mobile_app/_global.css | 10 -- .../stylesheets/mobile_app/utilities.css | 9 -- app/assets/stylesheets/{web => }/nav.css | 0 .../stylesheets/{web => }/notifications.css | 0 .../stylesheets/{web => }/pagination.css | 0 app/assets/stylesheets/{web => }/panels.css | 0 app/assets/stylesheets/{web => }/popup.css | 0 app/assets/stylesheets/{web => }/print.css | 0 .../stylesheets/{web => }/profile-layout.css | 0 app/assets/stylesheets/{web => }/pwa.css | 0 app/assets/stylesheets/{web => }/qr-codes.css | 0 .../stylesheets/{web => }/reactions.css | 0 app/assets/stylesheets/{web => }/reset.css | 0 .../{web => }/rich-text-content.css | 0 app/assets/stylesheets/{web => }/search.css | 0 .../stylesheets/{web => }/separators.css | 0 app/assets/stylesheets/{web => }/settings.css | 0 app/assets/stylesheets/{web => }/spinners.css | 0 app/assets/stylesheets/{web => }/steps.css | 0 app/assets/stylesheets/{web => }/syntax.css | 0 app/assets/stylesheets/{web => }/tags.css | 0 app/assets/stylesheets/{web => }/toggles.css | 0 app/assets/stylesheets/{web => }/tooltips.css | 0 app/assets/stylesheets/{web => }/trays.css | 0 app/assets/stylesheets/{web => }/user.css | 0 .../stylesheets/{web => }/utilities.css | 4 - app/assets/stylesheets/web/icons.css | 108 ------------------ app/helpers/application_helper.rb | 4 - app/models/application_platform.rb | 41 ------- app/views/layouts/shared/_head.html.erb | 2 +- 60 files changed, 9 insertions(+), 195 deletions(-) rename app/assets/stylesheets/{web => }/_global.css (99%) delete mode 100644 app/assets/stylesheets/android/utilities.css rename app/assets/stylesheets/{web => }/animation.css (100%) rename app/assets/stylesheets/{web => }/autoresize.css (100%) rename app/assets/stylesheets/{web => }/avatars.css (100%) rename app/assets/stylesheets/{web => }/bar.css (100%) rename app/assets/stylesheets/{web => }/base.css (100%) rename app/assets/stylesheets/{web => }/bubble.css (100%) rename app/assets/stylesheets/{web => }/buttons.css (100%) rename app/assets/stylesheets/{web => }/card-columns.css (100%) rename app/assets/stylesheets/{web => }/card-perma.css (100%) rename app/assets/stylesheets/{web => }/cards.css (100%) rename app/assets/stylesheets/{web => }/circled-text.css (100%) rename app/assets/stylesheets/{web => }/color-picker.css (100%) rename app/assets/stylesheets/{web => }/comments.css (100%) rename app/assets/stylesheets/{web => }/dialog.css (100%) rename app/assets/stylesheets/{web => }/dividers.css (100%) rename app/assets/stylesheets/{web => }/drag_and_drop.css (100%) rename app/assets/stylesheets/{web => }/events.css (99%) rename app/assets/stylesheets/{web => }/filters.css (100%) rename app/assets/stylesheets/{web => }/flash.css (100%) rename app/assets/stylesheets/{web => }/golden-effect.css (100%) rename app/assets/stylesheets/{web => }/header.css (95%) rename app/assets/stylesheets/{web => }/inputs.css (100%) delete mode 100644 app/assets/stylesheets/ios/utilities.css rename app/assets/stylesheets/{web => }/knobs.css (100%) rename app/assets/stylesheets/{web => }/layout.css (100%) rename app/assets/stylesheets/{web => }/lexxy.css (100%) rename app/assets/stylesheets/{web => }/lightbox.css (100%) rename app/assets/stylesheets/{web => }/markdown.css (91%) delete mode 100644 app/assets/stylesheets/mobile_app/_global.css delete mode 100644 app/assets/stylesheets/mobile_app/utilities.css rename app/assets/stylesheets/{web => }/nav.css (100%) rename app/assets/stylesheets/{web => }/notifications.css (100%) rename app/assets/stylesheets/{web => }/pagination.css (100%) rename app/assets/stylesheets/{web => }/panels.css (100%) rename app/assets/stylesheets/{web => }/popup.css (100%) rename app/assets/stylesheets/{web => }/print.css (100%) rename app/assets/stylesheets/{web => }/profile-layout.css (100%) rename app/assets/stylesheets/{web => }/pwa.css (100%) rename app/assets/stylesheets/{web => }/qr-codes.css (100%) rename app/assets/stylesheets/{web => }/reactions.css (100%) rename app/assets/stylesheets/{web => }/reset.css (100%) rename app/assets/stylesheets/{web => }/rich-text-content.css (100%) rename app/assets/stylesheets/{web => }/search.css (100%) rename app/assets/stylesheets/{web => }/separators.css (100%) rename app/assets/stylesheets/{web => }/settings.css (100%) rename app/assets/stylesheets/{web => }/spinners.css (100%) rename app/assets/stylesheets/{web => }/steps.css (100%) rename app/assets/stylesheets/{web => }/syntax.css (100%) rename app/assets/stylesheets/{web => }/tags.css (100%) rename app/assets/stylesheets/{web => }/toggles.css (100%) rename app/assets/stylesheets/{web => }/tooltips.css (100%) rename app/assets/stylesheets/{web => }/trays.css (100%) rename app/assets/stylesheets/{web => }/user.css (100%) rename app/assets/stylesheets/{web => }/utilities.css (99%) delete mode 100644 app/assets/stylesheets/web/icons.css diff --git a/app/assets/stylesheets/web/_global.css b/app/assets/stylesheets/_global.css similarity index 99% rename from app/assets/stylesheets/web/_global.css rename to app/assets/stylesheets/_global.css index a88c2c14c..dc928b40d 100644 --- a/app/assets/stylesheets/web/_global.css +++ b/app/assets/stylesheets/_global.css @@ -1,4 +1,4 @@ -@layer reset, base, components, modules, utilities, mobile_app, android, ios; +@layer reset, base, components, modules, utilities; :root { /* Spacing */ diff --git a/app/assets/stylesheets/android/utilities.css b/app/assets/stylesheets/android/utilities.css deleted file mode 100644 index e0d03f570..000000000 --- a/app/assets/stylesheets/android/utilities.css +++ /dev/null @@ -1,5 +0,0 @@ -@layer android { - .hide-in-android-app { - display: none; - } -} diff --git a/app/assets/stylesheets/web/animation.css b/app/assets/stylesheets/animation.css similarity index 100% rename from app/assets/stylesheets/web/animation.css rename to app/assets/stylesheets/animation.css diff --git a/app/assets/stylesheets/web/autoresize.css b/app/assets/stylesheets/autoresize.css similarity index 100% rename from app/assets/stylesheets/web/autoresize.css rename to app/assets/stylesheets/autoresize.css diff --git a/app/assets/stylesheets/web/avatars.css b/app/assets/stylesheets/avatars.css similarity index 100% rename from app/assets/stylesheets/web/avatars.css rename to app/assets/stylesheets/avatars.css diff --git a/app/assets/stylesheets/web/bar.css b/app/assets/stylesheets/bar.css similarity index 100% rename from app/assets/stylesheets/web/bar.css rename to app/assets/stylesheets/bar.css diff --git a/app/assets/stylesheets/web/base.css b/app/assets/stylesheets/base.css similarity index 100% rename from app/assets/stylesheets/web/base.css rename to app/assets/stylesheets/base.css diff --git a/app/assets/stylesheets/web/bubble.css b/app/assets/stylesheets/bubble.css similarity index 100% rename from app/assets/stylesheets/web/bubble.css rename to app/assets/stylesheets/bubble.css diff --git a/app/assets/stylesheets/web/buttons.css b/app/assets/stylesheets/buttons.css similarity index 100% rename from app/assets/stylesheets/web/buttons.css rename to app/assets/stylesheets/buttons.css diff --git a/app/assets/stylesheets/web/card-columns.css b/app/assets/stylesheets/card-columns.css similarity index 100% rename from app/assets/stylesheets/web/card-columns.css rename to app/assets/stylesheets/card-columns.css diff --git a/app/assets/stylesheets/web/card-perma.css b/app/assets/stylesheets/card-perma.css similarity index 100% rename from app/assets/stylesheets/web/card-perma.css rename to app/assets/stylesheets/card-perma.css diff --git a/app/assets/stylesheets/web/cards.css b/app/assets/stylesheets/cards.css similarity index 100% rename from app/assets/stylesheets/web/cards.css rename to app/assets/stylesheets/cards.css diff --git a/app/assets/stylesheets/web/circled-text.css b/app/assets/stylesheets/circled-text.css similarity index 100% rename from app/assets/stylesheets/web/circled-text.css rename to app/assets/stylesheets/circled-text.css diff --git a/app/assets/stylesheets/web/color-picker.css b/app/assets/stylesheets/color-picker.css similarity index 100% rename from app/assets/stylesheets/web/color-picker.css rename to app/assets/stylesheets/color-picker.css diff --git a/app/assets/stylesheets/web/comments.css b/app/assets/stylesheets/comments.css similarity index 100% rename from app/assets/stylesheets/web/comments.css rename to app/assets/stylesheets/comments.css diff --git a/app/assets/stylesheets/web/dialog.css b/app/assets/stylesheets/dialog.css similarity index 100% rename from app/assets/stylesheets/web/dialog.css rename to app/assets/stylesheets/dialog.css diff --git a/app/assets/stylesheets/web/dividers.css b/app/assets/stylesheets/dividers.css similarity index 100% rename from app/assets/stylesheets/web/dividers.css rename to app/assets/stylesheets/dividers.css diff --git a/app/assets/stylesheets/web/drag_and_drop.css b/app/assets/stylesheets/drag_and_drop.css similarity index 100% rename from app/assets/stylesheets/web/drag_and_drop.css rename to app/assets/stylesheets/drag_and_drop.css diff --git a/app/assets/stylesheets/web/events.css b/app/assets/stylesheets/events.css similarity index 99% rename from app/assets/stylesheets/web/events.css rename to app/assets/stylesheets/events.css index c6bd0ff2a..1dbdf0997 100644 --- a/app/assets/stylesheets/web/events.css +++ b/app/assets/stylesheets/events.css @@ -221,7 +221,6 @@ background-color: var(--color-canvas); grid-row-start: 1; inset-block-start: calc(var(--block-space) * -1); - margin-inline: calc(var(--main-padding) * -1); margin-block-end: var(--events-gap); padding-block: calc(var(--events-gap) * 3) var(--events-gap); position: sticky; diff --git a/app/assets/stylesheets/web/filters.css b/app/assets/stylesheets/filters.css similarity index 100% rename from app/assets/stylesheets/web/filters.css rename to app/assets/stylesheets/filters.css diff --git a/app/assets/stylesheets/web/flash.css b/app/assets/stylesheets/flash.css similarity index 100% rename from app/assets/stylesheets/web/flash.css rename to app/assets/stylesheets/flash.css diff --git a/app/assets/stylesheets/web/golden-effect.css b/app/assets/stylesheets/golden-effect.css similarity index 100% rename from app/assets/stylesheets/web/golden-effect.css rename to app/assets/stylesheets/golden-effect.css diff --git a/app/assets/stylesheets/web/header.css b/app/assets/stylesheets/header.css similarity index 95% rename from app/assets/stylesheets/web/header.css rename to app/assets/stylesheets/header.css index c2471e522..21553e358 100644 --- a/app/assets/stylesheets/web/header.css +++ b/app/assets/stylesheets/header.css @@ -39,7 +39,6 @@ .header__actions { display: flex; - align-items: center; font-size: var(--text-x-small); gap: var(--header-gap); inline-size: var(--header-actions-width); @@ -117,15 +116,17 @@ /* Optional class to stack header actions on small screens /* ------------------------------------------------------------------------ */ - .header--mobile-actions-stack { + /* .header--mobile-actions-stack { @media (max-width: 639px) { + grid-template-columns: 1fr 1fr; grid-template-areas: - "actions-start menu actions-end" - "title title title"; + "menu menu" + "actions-start actions-end" + "title title"; .header__title { margin-block-start: 0.25rem; } } - } + } */ } diff --git a/app/assets/stylesheets/web/inputs.css b/app/assets/stylesheets/inputs.css similarity index 100% rename from app/assets/stylesheets/web/inputs.css rename to app/assets/stylesheets/inputs.css diff --git a/app/assets/stylesheets/ios/utilities.css b/app/assets/stylesheets/ios/utilities.css deleted file mode 100644 index 0dfeada57..000000000 --- a/app/assets/stylesheets/ios/utilities.css +++ /dev/null @@ -1,5 +0,0 @@ -@layer ios { - .hide-in-ios-app { - display: none; - } -} diff --git a/app/assets/stylesheets/web/knobs.css b/app/assets/stylesheets/knobs.css similarity index 100% rename from app/assets/stylesheets/web/knobs.css rename to app/assets/stylesheets/knobs.css diff --git a/app/assets/stylesheets/web/layout.css b/app/assets/stylesheets/layout.css similarity index 100% rename from app/assets/stylesheets/web/layout.css rename to app/assets/stylesheets/layout.css diff --git a/app/assets/stylesheets/web/lexxy.css b/app/assets/stylesheets/lexxy.css similarity index 100% rename from app/assets/stylesheets/web/lexxy.css rename to app/assets/stylesheets/lexxy.css diff --git a/app/assets/stylesheets/web/lightbox.css b/app/assets/stylesheets/lightbox.css similarity index 100% rename from app/assets/stylesheets/web/lightbox.css rename to app/assets/stylesheets/lightbox.css diff --git a/app/assets/stylesheets/web/markdown.css b/app/assets/stylesheets/markdown.css similarity index 91% rename from app/assets/stylesheets/web/markdown.css rename to app/assets/stylesheets/markdown.css index fe26141c2..f6aef0e39 100644 --- a/app/assets/stylesheets/web/markdown.css +++ b/app/assets/stylesheets/markdown.css @@ -3,7 +3,7 @@ --opacity: 0.5; --size: 0.8em; - background: url("/link.svg") no-repeat center bottom 0.2em; + background: url(link.svg) no-repeat center bottom 0.2em; background-size: var(--size); block-size: 1em; color: var(--color-link); diff --git a/app/assets/stylesheets/mobile_app/_global.css b/app/assets/stylesheets/mobile_app/_global.css deleted file mode 100644 index 5d0648df5..000000000 --- a/app/assets/stylesheets/mobile_app/_global.css +++ /dev/null @@ -1,10 +0,0 @@ -@layer mobile_app { - /* Use custom insets to account for floating navigation elements. - The mobile apps can inject their own inset value based on native elements on screen. */ - :root { - --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); - --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); - --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); - --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); - } -} diff --git a/app/assets/stylesheets/mobile_app/utilities.css b/app/assets/stylesheets/mobile_app/utilities.css deleted file mode 100644 index 85e9a9848..000000000 --- a/app/assets/stylesheets/mobile_app/utilities.css +++ /dev/null @@ -1,9 +0,0 @@ -@layer mobile_app { - .hide-in-mobile-app { - display: none; - } - - .show-in-mobile-app { - display: unset; - } -} diff --git a/app/assets/stylesheets/web/nav.css b/app/assets/stylesheets/nav.css similarity index 100% rename from app/assets/stylesheets/web/nav.css rename to app/assets/stylesheets/nav.css diff --git a/app/assets/stylesheets/web/notifications.css b/app/assets/stylesheets/notifications.css similarity index 100% rename from app/assets/stylesheets/web/notifications.css rename to app/assets/stylesheets/notifications.css diff --git a/app/assets/stylesheets/web/pagination.css b/app/assets/stylesheets/pagination.css similarity index 100% rename from app/assets/stylesheets/web/pagination.css rename to app/assets/stylesheets/pagination.css diff --git a/app/assets/stylesheets/web/panels.css b/app/assets/stylesheets/panels.css similarity index 100% rename from app/assets/stylesheets/web/panels.css rename to app/assets/stylesheets/panels.css diff --git a/app/assets/stylesheets/web/popup.css b/app/assets/stylesheets/popup.css similarity index 100% rename from app/assets/stylesheets/web/popup.css rename to app/assets/stylesheets/popup.css diff --git a/app/assets/stylesheets/web/print.css b/app/assets/stylesheets/print.css similarity index 100% rename from app/assets/stylesheets/web/print.css rename to app/assets/stylesheets/print.css diff --git a/app/assets/stylesheets/web/profile-layout.css b/app/assets/stylesheets/profile-layout.css similarity index 100% rename from app/assets/stylesheets/web/profile-layout.css rename to app/assets/stylesheets/profile-layout.css diff --git a/app/assets/stylesheets/web/pwa.css b/app/assets/stylesheets/pwa.css similarity index 100% rename from app/assets/stylesheets/web/pwa.css rename to app/assets/stylesheets/pwa.css diff --git a/app/assets/stylesheets/web/qr-codes.css b/app/assets/stylesheets/qr-codes.css similarity index 100% rename from app/assets/stylesheets/web/qr-codes.css rename to app/assets/stylesheets/qr-codes.css diff --git a/app/assets/stylesheets/web/reactions.css b/app/assets/stylesheets/reactions.css similarity index 100% rename from app/assets/stylesheets/web/reactions.css rename to app/assets/stylesheets/reactions.css diff --git a/app/assets/stylesheets/web/reset.css b/app/assets/stylesheets/reset.css similarity index 100% rename from app/assets/stylesheets/web/reset.css rename to app/assets/stylesheets/reset.css diff --git a/app/assets/stylesheets/web/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css similarity index 100% rename from app/assets/stylesheets/web/rich-text-content.css rename to app/assets/stylesheets/rich-text-content.css diff --git a/app/assets/stylesheets/web/search.css b/app/assets/stylesheets/search.css similarity index 100% rename from app/assets/stylesheets/web/search.css rename to app/assets/stylesheets/search.css diff --git a/app/assets/stylesheets/web/separators.css b/app/assets/stylesheets/separators.css similarity index 100% rename from app/assets/stylesheets/web/separators.css rename to app/assets/stylesheets/separators.css diff --git a/app/assets/stylesheets/web/settings.css b/app/assets/stylesheets/settings.css similarity index 100% rename from app/assets/stylesheets/web/settings.css rename to app/assets/stylesheets/settings.css diff --git a/app/assets/stylesheets/web/spinners.css b/app/assets/stylesheets/spinners.css similarity index 100% rename from app/assets/stylesheets/web/spinners.css rename to app/assets/stylesheets/spinners.css diff --git a/app/assets/stylesheets/web/steps.css b/app/assets/stylesheets/steps.css similarity index 100% rename from app/assets/stylesheets/web/steps.css rename to app/assets/stylesheets/steps.css diff --git a/app/assets/stylesheets/web/syntax.css b/app/assets/stylesheets/syntax.css similarity index 100% rename from app/assets/stylesheets/web/syntax.css rename to app/assets/stylesheets/syntax.css diff --git a/app/assets/stylesheets/web/tags.css b/app/assets/stylesheets/tags.css similarity index 100% rename from app/assets/stylesheets/web/tags.css rename to app/assets/stylesheets/tags.css diff --git a/app/assets/stylesheets/web/toggles.css b/app/assets/stylesheets/toggles.css similarity index 100% rename from app/assets/stylesheets/web/toggles.css rename to app/assets/stylesheets/toggles.css diff --git a/app/assets/stylesheets/web/tooltips.css b/app/assets/stylesheets/tooltips.css similarity index 100% rename from app/assets/stylesheets/web/tooltips.css rename to app/assets/stylesheets/tooltips.css diff --git a/app/assets/stylesheets/web/trays.css b/app/assets/stylesheets/trays.css similarity index 100% rename from app/assets/stylesheets/web/trays.css rename to app/assets/stylesheets/trays.css diff --git a/app/assets/stylesheets/web/user.css b/app/assets/stylesheets/user.css similarity index 100% rename from app/assets/stylesheets/web/user.css rename to app/assets/stylesheets/user.css diff --git a/app/assets/stylesheets/web/utilities.css b/app/assets/stylesheets/utilities.css similarity index 99% rename from app/assets/stylesheets/web/utilities.css rename to app/assets/stylesheets/utilities.css index 02ed8ee19..d139f28f9 100644 --- a/app/assets/stylesheets/web/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -269,10 +269,6 @@ } } - .show-in-mobile-app { - display: none; - } - .hide-scrollbar { -ms-overflow-style: none; /* Edge */ scrollbar-width: none; /* FF */ diff --git a/app/assets/stylesheets/web/icons.css b/app/assets/stylesheets/web/icons.css deleted file mode 100644 index 6d1697bf9..000000000 --- a/app/assets/stylesheets/web/icons.css +++ /dev/null @@ -1,108 +0,0 @@ -@layer components { - .icon { - -webkit-touch-callout: none; - background-color: currentColor; - block-size: var(--icon-size, 1em); - display: inline-block; - flex-shrink: 0; - inline-size: var(--icon-size, 1em); - mask-image: var(--svg); - mask-position: center; - mask-repeat: no-repeat; - mask-size: var(--icon-size, 1em); - pointer-events: none; - user-select: none; - } - - img.icon { - background: none; - } - - .icon--37signals { --svg: url("/37signals.svg"); } - .icon--add { --svg: url("/add.svg"); } - .icon--add--meta { --svg: url("/add--meta.svg"); } - .icon--arrow-left { --svg: url("/arrow-left.svg"); } - .icon--arrow-right { --svg: url("/arrow-right.svg"); } - .icon--arrow-up { --svg: url("/arrow-up.svg"); } - .icon--art { --svg: url("/art.svg"); } - .icon--assigned { --svg: url("/assigned.svg"); } - .icon--attachment { --svg: url("/attachment.svg"); } - .icon--bell-alert { --svg: url("/bell-alert.svg"); } - .icon--bell-off { --svg: url("/bell-off.svg"); } - .icon--bell { --svg: url("/bell.svg"); } - .icon--bolt { --svg: url("/bolt.svg"); } - .icon--bookmark-outline { --svg: url("/bookmark-outline.svg"); } - .icon--bookmark { --svg: url("/bookmark.svg"); } - .icon--boost { --svg: url("/boost.svg"); } - .icon--camera { --svg: url("/camera.svg"); } - .icon--caret-down { --svg: url("/caret-down.svg"); } - .icon--check { --svg: url("/check.svg"); } - .icon--check-circle { --svg: url("/check-circle.svg"); } - .icon--check-all { --svg: url("/check-all.svg"); } - .icon--clipboard { --svg: url("/clipboard.svg"); } - .icon--close { --svg: url("/close.svg"); } - .icon--close-circle { --svg: url("/close-circle.svg"); } - .icon--collapse { --svg: url("/collapse.svg"); } - .icon--board { --svg: url("/board.svg"); } - .icon--board-add { --svg: url("/board-add.svg"); } - .icon--column-left { --svg: url("/column-left.svg"); } - .icon--column-right { --svg: url("/column-right.svg"); } - .icon--comment { --svg: url("/comment.svg"); } - .icon--copy-paste { --svg: url("/copy-paste.svg"); } - .icon--crown { --svg: url("/crown.svg"); } - .icon--email { --svg: url("/email.svg"); } - .icon--everyone { --svg: url("/everyone.svg"); } - .icon--expand { --svg: url("/expand.svg"); } - .icon--gear { --svg: url("/gear.svg"); } - .icon--grid { --svg: url("/grid.svg"); } - .icon--filter { --svg: url("/filter.svg"); } - .icon--fizzy { --svg: url("/fizzy.svg"); } - .icon--globe { --svg: url("/globe.svg"); } - .icon--golden-ticket { --svg: url("/golden-ticket.svg"); } - .icon--home { --svg: url("/home.svg"); } - .icon--install { --svg: url("/install.svg"); } - .icon--install { --svg: url("/install.svg"); } - .icon--install-edge { --svg: url("/install-edge.svg"); } - .icon--lifebuoy { --svg: url("/lifebuoy.svg"); } - .icon--lock { --svg: url("/lock.svg"); } - .icon--logout { --svg: url("/logout.svg"); } - .icon--marker { --svg: url("/marker.svg"); } - .icon--maximize { --svg: url("/maximize.svg"); } - .icon--menu { --svg: url("/menu.svg"); } - .icon--menu-dots-horizontal { --svg: url("/menu-dots-horizontal.svg"); } - .icon--menu-dots-vertical { --svg: url("/menu-dots-vertical.svg"); } - .icon--minus { --svg: url("/minus.svg"); } - .icon--move { --svg: url("/move.svg"); } - .icon--notification-bell-access-only { --svg: url("/bell.svg"); } - .icon--notification-bell-watching { --svg: url("/bell-off.svg"); } - .icon--notification-bell-reverse-access-only { --svg: url("/bell-off.svg"); } - .icon--notification-bell-reverse-watching { --svg: url("/bell.svg"); } - .icon--password { --svg: url("/password.svg"); } - .icon--pencil { --svg: url("/pencil.svg"); } - .icon--person { --svg: url("/person.svg"); } - .icon--person-add { --svg: url("/person-add.svg"); } - .icon--picture-add { --svg: url("/picture-add.svg"); } - .icon--picture-double { --svg: url("/picture-double.svg"); } - .icon--picture-remove { --svg: url("/picture-remove.svg"); } - .icon--picture-zoom { --svg: url("/picture-zoom.svg"); } - .icon--pinned { --svg: url("/pinned.svg"); } - .icon--qr-code { --svg: url("/qr-code.svg"); } - .icon--reaction { --svg: url("/reaction.svg"); } - .icon--refresh { --svg: url("/refresh.svg"); } - .icon--refresh--meta { --svg: url("/refresh--meta.svg"); } - .icon--remove { --svg: url("/remove.svg"); } - .icon--rename { --svg: url("/rename.svg"); } - .icon--search { --svg: url("/search.svg"); } - .icon--settings { --svg: url("/settings.svg"); } - .icon--share { --svg: url("/share.svg"); } - .icon--sliders { --svg: url("/sliders.svg"); } - .icon--switch { --svg: url("/switch.svg"); } - .icon--tag { --svg: url("/tag.svg"); } - .icon--tag-outline { --svg: url("/tag-outline.svg"); } - .icon--thumb-up { --svg: url("/thumb-up.svg"); } - .icon--trash { --svg: url("/trash.svg"); } - .icon--unpinned { --svg: url("/unpinned.svg"); } - .icon--unseen { --svg: url("/unseen.svg"); } - .icon--world { --svg: url("/world.svg"); } - .icon--youtube { --svg: url("/youtube.svg"); } -} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9733a7eec..b3657c76e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,8 +1,4 @@ module ApplicationHelper - def stylesheet_link_tags - stylesheet_link_tag *platform.stylesheet_paths, "data-turbo-track": "reload" - end - def page_title_tag account_name = if Current.account && Current.session&.identity&.users&.many? Current.account&.name diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index c484bf335..500f4adc6 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -1,6 +1,4 @@ class ApplicationPlatform < PlatformAgent - SCOPED_STYLESHEET_PATHS = {} - def ios? match? /iPhone|iPad/ end @@ -41,18 +39,6 @@ class ApplicationPlatform < PlatformAgent operating_system == "Windows" end - def ios_app? - match? /Fizzy iOS/ - end - - def android_app? - match? /Fizzy Android/ - end - - def mobile_app? - ios_app? || android_app? - end - def operating_system case user_agent.platform when /Android/ then "Android" @@ -65,31 +51,4 @@ class ApplicationPlatform < PlatformAgent os =~ /Linux/ ? "Linux" : os end end - - def stylesheet_paths - scoped_stylesheet_paths("web") + - (mobile_app? ? scoped_stylesheet_paths("mobile_app") : []) + - scoped_stylesheet_paths(stylesheet_asset_name) - end - - private - def stylesheet_asset_name - case - when android_app? then "android" - when ios_app? then "ios" - else "desktop" - end - end - - def scoped_stylesheet_paths(scope = css_asset_name) - # Allow new stylesheets to be added in dev/test without restarting server - SCOPED_STYLESHEET_PATHS.clear if Rails.env.development? - - SCOPED_STYLESHEET_PATHS[scope] ||= - Rails.root.join("app/assets/stylesheets").then do |stylesheet_root| - stylesheet_root.glob("#{scope}/**/*.css").collect do |path| - path.to_s.remove(stylesheet_root.to_s + "/", ".css") - end - end - end end diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 61b19713c..4be536454 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -16,7 +16,7 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> <%= render "layouts/theme_preference" %> - <%= stylesheet_link_tags %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %> <%= javascript_importmap_tags %> From a68bd2775dab89187ea90f5f7bbdc6776e11c110 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 16 Dec 2025 09:35:26 -0800 Subject: [PATCH 207/318] Fix Tailscale host match. Rails wraps the regexp in its own anchors and port-match. --- config/environments/development.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/environments/development.rb b/config/environments/development.rb index e40d4fe16..1af03fdd4 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -89,8 +89,8 @@ Rails.application.configure do "fizzy.localhost", "localhost", "127.0.0.1", - /^fizzy-\d+(:\d+)?$/, # review apps: fizzy-123:3000 - /\.ts\.net$/ # tailscale serve: hostname.tail1234.ts.net + /fizzy-\d+/, # review apps: fizzy-123, fizzy-456:3000 + /.*\.ts\.net/, # tailscale serve: hostname.tail1234.ts.net ] # Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs) From 97ffe00672847c49e2b1030a3587d967314ab192 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 16 Dec 2025 09:53:47 -0800 Subject: [PATCH 208/318] My life for a trailing comma --- config/environments/development.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/environments/development.rb b/config/environments/development.rb index 1af03fdd4..6088b670d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -89,8 +89,8 @@ Rails.application.configure do "fizzy.localhost", "localhost", "127.0.0.1", - /fizzy-\d+/, # review apps: fizzy-123, fizzy-456:3000 - /.*\.ts\.net/, # tailscale serve: hostname.tail1234.ts.net + /fizzy-\d+/, # review apps: fizzy-123, fizzy-456:3000 + /.*\.ts\.net/ # tailscale serve: hostname.tail1234.ts.net ] # Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs) From bf6ddcb933ed2545ecdcaa74e767dbc30826ca3f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 16 Dec 2025 09:54:53 -0800 Subject: [PATCH 209/318] Add --tailscale flag to bin/dev for remote access (#2168) Enables sharing dev sites over Tailscale serve. Uses port convention 4xxxx (prepending 4 to the dev port, echoing TLS 443) so multiple apps can run simultaneously on different ports. Usage: bin/dev --tailscale --- bin/dev | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/bin/dev b/bin/dev index d01747dd3..21ef0eeeb 100755 --- a/bin/dev +++ b/bin/dev @@ -1,8 +1,13 @@ #!/usr/bin/env sh -bin/rails runner - </dev/null 2>&1; then + echo "Error: tailscale not found" >&2 + exit 1 + fi + + TS_STATUS=$(tailscale status --self --json 2>&1) + if [ $? -ne 0 ]; then + echo "Error: tailscale not logged in" >&2 + exit 1 + fi + + TS_HOSTNAME=$(echo "$TS_STATUS" | jq -r '.Self.DNSName | rtrimstr(".")') + TS_PORT="4$PORT" + + stop_tailscale() { tailscale serve --https=$TS_PORT off >/dev/null 2>&1; } + trap stop_tailscale EXIT INT TERM + + tailscale serve --bg --https=$TS_PORT "http://fizzy.localhost:$PORT" >/dev/null 2>&1 + echo "Login with david@example.com to: https://$TS_HOSTNAME:$TS_PORT/" +else + echo "Login with david@example.com to: http://fizzy.localhost:$PORT/" +fi + +./bin/rails server -p $PORT From 52bdc8d1a726c110062f988352a9fedb5f5dd1fc Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 16 Dec 2025 18:55:45 +0100 Subject: [PATCH 210/318] Fix typo --- app/views/cards/assignments/new.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index dc6fe1166..048bacf62 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -29,7 +29,7 @@ <% end %> <% end %> From 18b3319a94ab452d2bdfdaca84c26b0b34f3d628 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 8 Dec 2025 10:05:03 -0500 Subject: [PATCH 211/318] Extract hex_to_base36 and base36_to_hex methods in Uuid type replacing normalize_base36. This should make it easier to convert UUIDs between hex and base36 formats, useful when debugging production logs that contain hex UUIDs. For example, if in a log you see: User Load (0.3ms) SELECT `users`.* FROM `users` WHERE `users`.`account_id` = x'019afab815897a4f920f3a24fab75400' AND `users`.`id` = x'019afab815a2790688c58c7f8326e700' LIMIT 1 and you want to find that account or that user in the console, you can now do: id = ActiveRecord::Type::Uuid.hex_to_base36("019afab815897a4f920f3a24fab75400") => "03f6bilrvt3oghzhurll4pp8g" --- lib/rails_ext/active_record_uuid_type.rb | 28 +++++++++++++----------- test/test_helper.rb | 3 +-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/lib/rails_ext/active_record_uuid_type.rb b/lib/rails_ext/active_record_uuid_type.rb index 02bdf3ed3..f2004f249 100644 --- a/lib/rails_ext/active_record_uuid_type.rb +++ b/lib/rails_ext/active_record_uuid_type.rb @@ -4,32 +4,34 @@ module ActiveRecord class Uuid < Binary BASE36_LENGTH = 25 # 36^25 > 2^128 - def self.generate - uuid = SecureRandom.uuid_v7 - hex = uuid.delete("-") - normalize_base36(hex.to_i(16)) - end + class << self + def generate + uuid = SecureRandom.uuid_v7 + hex = uuid.delete("-") + hex_to_base36(hex) + end - def self.normalize_base36(integer) - integer.to_s(36).rjust(BASE36_LENGTH, "0") + def hex_to_base36(hex) + hex.to_i(16).to_s(36).rjust(BASE36_LENGTH, "0") + end + + def base36_to_hex(base36) + base36.to_s.to_i(36).to_s(16).rjust(32, "0") + end end def serialize(value) return unless value - binary = hex(value).scan(/../).map(&:hex).pack("C*") + binary = Uuid.base36_to_hex(value).scan(/../).map(&:hex).pack("C*") super(binary) end - def hex(value) - value.to_s.to_i(36).to_s(16).rjust(32, "0") - end - def deserialize(value) return unless value hex = value.to_s.unpack1("H*") - Uuid.normalize_base36(hex.to_i(16)) + Uuid.hex_to_base36(hex) end def cast(value) diff --git a/test/test_helper.rb b/test/test_helper.rb index 12ab491ed..14893b030 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -154,8 +154,7 @@ module FixturesTestHelper # Format as UUID string and convert to base36 (25 chars) uuid = "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x" % bytes - hex = uuid.delete("-") - hex.to_i(16).to_s(36).rjust(25, "0") + ActiveRecord::Type::Uuid.hex_to_base36(uuid.delete("-")) end end end From 8659e19671361fe1cecb2d94f4cadcb89528c0f0 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 16 Dec 2025 14:21:48 -0600 Subject: [PATCH 212/318] Collapse and expand system comments --- app/assets/images/history.svg | 1 + app/assets/stylesheets/comments.css | 38 ++++++++++++++++++++-- app/assets/stylesheets/icons.css | 1 + app/helpers/messages_helper.rb | 4 ++- app/views/cards/comments/_comment.html.erb | 7 +++- 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 app/assets/images/history.svg diff --git a/app/assets/images/history.svg b/app/assets/images/history.svg new file mode 100644 index 000000000..20c490c97 --- /dev/null +++ b/app/assets/images/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 587749fab..9021ee394 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -23,6 +23,7 @@ } .comment { + display: flex; margin-inline: auto; max-inline-size: var(--comment-max); position: relative; @@ -85,11 +86,35 @@ background-color: var(--color-ink-lightest); } + .comment__history { + background-color: var(--color-ink-lightest); + display: none; + inset: calc(var(--comment-padding-inline) / 2.5) calc(var(--comment-padding-inline) / 1.5) auto auto; margin-inline-end: calc(var(--comment-padding-inline) / -2.5); + position: absolute; + } + .comment--system { --comment-padding-block: var(--block-space-half); + display: none; max-inline-size: var(--comment-max); text-align: center; + transition: var(--dialog-duration) allow-discrete; + transition-property: display; + + .comments--system-expanded & { + display: flex; + } + + &:nth-last-child(1 of &):not(:has(~ * &) *):not(:has(~ &) *):not(:has(~ * &)):not(:has(&)) { + /* This unholy selector targets the last system comment in the document */ + /* Hat-tip, https://css-tip.com/last-element-dom/ */ + display: flex; + + .comment__history { + display: grid; + } + } &::before { /* Make up space for lack of avatar */ @@ -122,12 +147,21 @@ } .comment__content { + --stripe-color: var(--color-ink-lightest); + background-image: repeating-linear-gradient( 45deg in srgb, var(--color-canvas) 0 1px, - var(--color-ink-lightest) 1px 10px - ); + var(--stripe-color) 1px 10px); padding-inline: var(--comment-padding-inline); + + .comments--system-expanded .comment--system & { + --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); + } + + .comment__history { + background-color: var(--stripe-color); + } } .reactions { diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 1ca7800fa..42a3a21ec 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -59,6 +59,7 @@ .icon--fizzy { --svg: url("fizzy.svg"); } .icon--globe { --svg: url("globe.svg "); } .icon--golden-ticket { --svg: url("golden-ticket.svg "); } + .icon--history { --svg: url("history.svg "); } .icon--home { --svg: url("home.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index f0e774882..6eb70f2cc 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -3,6 +3,8 @@ module MessagesHelper turbo_frame_tag dom_id(card, :messages), class: "comments gap center", style: "--card-color: #{card.color}", - role: "group", aria: { label: "Messages" }, & + role: "group", + aria: { label: "Messages" }, + data: { controller: "toggle-class", toggle_class_toggle_class: "comments--system-expanded" }, & end end diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 6a825e9ec..de2b94c0d 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -2,7 +2,7 @@ <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> <%= turbo_frame_tag comment, :container do %> <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%> -
"> +
"> @@ -20,6 +20,11 @@ <% end %> + + <%= link_to edit_card_comment_path(comment.card, comment), class: "comment__edit btn btn--circle borderless translucent", data: { only_visible_to_you: true } do %> <%= icon_tag "menu-dots-horizontal" %> From c691d39ecf4cbcd555fe7099533ac1361974e201 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 16 Dec 2025 12:09:35 -0800 Subject: [PATCH 213/318] Integrate fizzy-saas as vendored gem at saas/ - Update Gemfile.saas to use path: "saas" instead of GitHub source - Update config/database.yml to reference local saas/ directory - Update bin/setup to source saas/bin/setup directly - Remove redundant dotfiles (ruby-version, gitignore, rubocop, github workflows) - Add saas/db exclusions to root rubocop config --- .rubocop.yml | 2 ++ Gemfile.saas | 2 +- Gemfile.saas.lock | 43 +++++++++++++------------- bin/setup | 3 +- config/database.yml | 2 +- saas/.github/workflows/lint.yml | 25 --------------- saas/.gitignore | 10 ------ saas/.rubocop.yml | 13 -------- saas/.ruby-version | 1 - saas/Dockerfile | 2 ++ saas/config/environments/production.rb | 2 +- 11 files changed, 29 insertions(+), 76 deletions(-) delete mode 100644 saas/.github/workflows/lint.yml delete mode 100644 saas/.gitignore delete mode 100644 saas/.rubocop.yml delete mode 100644 saas/.ruby-version diff --git a/.rubocop.yml b/.rubocop.yml index 16773cd4c..c0d945b70 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,3 +11,5 @@ AllCops: Exclude: - 'db/migrate/**/*' - 'db/schema*.rb' + - 'saas/db/migrate/**/*' + - 'saas/db/saas_schema.rb' diff --git a/Gemfile.saas b/Gemfile.saas index 2366e80aa..05e6bbabd 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -6,7 +6,7 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "activeresource", require: "active_resource" gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" -gem "fizzy-saas", bc: "fizzy-saas" +gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index bd811d9d6..576bbc731 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -19,28 +19,6 @@ GIT rails (>= 7.0) rainbow -GIT - remote: https://github.com/basecamp/fizzy-saas - revision: 8eaa692316bb27ed73bd628b4735082585498ca0 - specs: - fizzy-saas (0.1.0) - audits1984 - console1984 - prometheus-client-mmap (~> 1.4.0) - queenbee - rails (>= 8.1.0.beta1) - rails_structured_logging - sentry-rails - sentry-ruby - yabeda - yabeda-actioncable - yabeda-activejob - yabeda-gc - yabeda-http_requests - yabeda-prometheus-mmap - yabeda-puma-plugin - yabeda-rails (>= 0.10) - GIT remote: https://github.com/basecamp/lexxy revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 @@ -181,6 +159,27 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +PATH + remote: saas + specs: + fizzy-saas (0.1.0) + audits1984 + console1984 + prometheus-client-mmap (~> 1.4.0) + queenbee + rails (>= 8.1.0.beta1) + rails_structured_logging + sentry-rails + sentry-ruby + yabeda + yabeda-actioncable + yabeda-activejob + yabeda-gc + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails (>= 0.10) + GEM remote: https://rubygems.org/ specs: diff --git a/bin/setup b/bin/setup index e1a9dabb8..a49eaa9d4 100755 --- a/bin/setup +++ b/bin/setup @@ -136,8 +136,7 @@ bundle config set --local auto_install true step "Installing RubyGems" bundle install if [ -n "$SAAS" ]; then - saas_setup=$(bundle show fizzy-saas)/bin/setup - source "$saas_setup" + source "$app_root/saas/bin/setup" else if [ "$DATABASE_ADAPTER" = "mysql" ]; then oss_mysql_setup diff --git a/config/database.yml b/config/database.yml index d5ab7dbc0..359ac6655 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,6 +1,6 @@ <% config_path = if Fizzy.saas? - gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir + gem_path = Rails.root.join("saas").to_s File.join(gem_path, "config", "database.yml") else File.join("config", "database.#{Fizzy.db_adapter}.yml") diff --git a/saas/.github/workflows/lint.yml b/saas/.github/workflows/lint.yml deleted file mode 100644 index bfcd0ef2d..000000000 --- a/saas/.github/workflows/lint.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Lint - -on: - pull_request: - -permissions: - contents: read - -jobs: - rubocop: - name: Rubocop - runs-on: ubuntu-latest - - env: - BUNDLE_GITHUB__COM: ${{ format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) }} - - steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Run Rubocop - run: bundle exec rubocop diff --git a/saas/.gitignore b/saas/.gitignore deleted file mode 100644 index a3ee5aad3..000000000 --- a/saas/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -/.bundle/ -/doc/ -/log/*.log -/pkg/ -/tmp/ -/test/dummy/db/*.sqlite3 -/test/dummy/db/*.sqlite3-* -/test/dummy/log/*.log -/test/dummy/storage/ -/test/dummy/tmp/ diff --git a/saas/.rubocop.yml b/saas/.rubocop.yml deleted file mode 100644 index 16773cd4c..000000000 --- a/saas/.rubocop.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Omakase Ruby styling for Rails -inherit_gem: { rubocop-rails-omakase: rubocop.yml } - -# Overwrite or add rules to create your own house style -# -# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` -# Layout/SpaceInsideArrayLiteralBrackets: -# Enabled: false - -AllCops: - Exclude: - - 'db/migrate/**/*' - - 'db/schema*.rb' diff --git a/saas/.ruby-version b/saas/.ruby-version deleted file mode 100644 index 2aa513199..000000000 --- a/saas/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.4.7 diff --git a/saas/Dockerfile b/saas/Dockerfile index c30972ad1..92fd9c4ec 100644 --- a/saas/Dockerfile +++ b/saas/Dockerfile @@ -25,6 +25,8 @@ RUN apt-get update -qq && \ # Install application gems COPY Gemfile Gemfile.lock Gemfile.saas Gemfile.saas.lock .ruby-version ./ COPY lib/fizzy.rb ./lib/fizzy.rb +COPY saas/fizzy-saas.gemspec ./saas/ +COPY saas/lib/fizzy/saas/version.rb ./saas/lib/fizzy/saas/ RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-permabundle-${RUBY_VERSION},sharing=locked,target=/permabundle \ gem install bundler && \ BUNDLE_PATH=/permabundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \ diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb index 7add7804b..3cad5c015 100644 --- a/saas/config/environments/production.rb +++ b/saas/config/environments/production.rb @@ -11,7 +11,7 @@ Rails.application.configure do # Content Security Policy config.x.content_security_policy.report_only = false - config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" + config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" # gitleaks:allow (public DSN for CSP reports) config.x.content_security_policy.script_src = "https://challenges.cloudflare.com" config.x.content_security_policy.frame_src = "https://challenges.cloudflare.com" config.x.content_security_policy.connect_src = "https://storage.basecamp.com" From 5949b421e96f87565f3efcca5aa6baa5d72d6ce9 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 16 Dec 2025 16:09:09 -0600 Subject: [PATCH 214/318] Hide the 'Show history' button if there are no other entries to expand --- app/assets/stylesheets/comments.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 9021ee394..45c98fb13 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -116,6 +116,14 @@ } } + &:nth-child(1 of &):not(:has(&) ~ * *):not(:has(&) ~ *):not(& ~ * *):not(& *) { + /* Targets the first system comment in the document to effectively */ + /* hide the "Show history" button if there's only one entry */ + .comment__history { + display: none; + } + } + &::before { /* Make up space for lack of avatar */ content: ""; From a755ecdec9d1a2c4072c010525d2a95b0f13f5f0 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 16 Dec 2025 14:36:49 -0800 Subject: [PATCH 215/318] Must be run as sudo --- bin/dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/dev b/bin/dev index 21ef0eeeb..8c03122d0 100755 --- a/bin/dev +++ b/bin/dev @@ -35,7 +35,7 @@ if [ "$USE_TAILSCALE" = "1" ]; then stop_tailscale() { tailscale serve --https=$TS_PORT off >/dev/null 2>&1; } trap stop_tailscale EXIT INT TERM - tailscale serve --bg --https=$TS_PORT "http://fizzy.localhost:$PORT" >/dev/null 2>&1 + sudo tailscale serve --bg --https=$TS_PORT "http://fizzy.localhost:$PORT" >/dev/null 2>&1 echo "Login with david@example.com to: https://$TS_HOSTNAME:$TS_PORT/" else echo "Login with david@example.com to: http://fizzy.localhost:$PORT/" From a5a0cda295885b0da37a1d452d647c2838ca20dc Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 16 Dec 2025 14:35:25 -0800 Subject: [PATCH 216/318] Fix tailscale serve: use localhost:PORT, helpful error on Linux Tailscale serve only supports localhost or 127.0.0.1 as proxy targets. On Linux, prompts user to run `sudo tailscale set --operator=$USER` if access denied. --- bin/dev | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/dev b/bin/dev index 8c03122d0..f5e4d8d67 100755 --- a/bin/dev +++ b/bin/dev @@ -35,7 +35,11 @@ if [ "$USE_TAILSCALE" = "1" ]; then stop_tailscale() { tailscale serve --https=$TS_PORT off >/dev/null 2>&1; } trap stop_tailscale EXIT INT TERM - sudo tailscale serve --bg --https=$TS_PORT "http://fizzy.localhost:$PORT" >/dev/null 2>&1 + tailscale serve --bg --https=$TS_PORT localhost:$PORT >/dev/null 2>&1 + if ! tailscale serve status --json 2>/dev/null | jq -e ".TCP.\"$TS_PORT\"" >/dev/null 2>&1; then + echo "Error: tailscale serve failed. On Linux, run once: sudo tailscale set --operator=\$USER" >&2 + exit 1 + fi echo "Login with david@example.com to: https://$TS_HOSTNAME:$TS_PORT/" else echo "Login with david@example.com to: http://fizzy.localhost:$PORT/" From 5b7aa67bb3acf40d42efeff35b69e7a5b616e881 Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Wed, 17 Dec 2025 08:56:08 +0100 Subject: [PATCH 217/318] Add platform attribute --- app/models/application_platform.rb | 16 ++++++++++++++++ app/views/layouts/application.html.erb | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index 500f4adc6..9b2695cd5 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -35,10 +35,26 @@ class ApplicationPlatform < PlatformAgent !mobile? end + def native? + match? /Hotwire Native/ + end + def windows? operating_system == "Windows" end + def type + if native? && android? + "native android" + elsif native? && ios? + "native ios" + elsif mobile? + "mobile web" + else + "desktop web" + end + end + def operating_system case user_agent.platform when /Android/ then "Android" diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 6aef1f1bc..79b679637 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -2,7 +2,7 @@ <%= render "layouts/shared/head" %> - + <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %> - <%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large", + <%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase", autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true, autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %> From bd0a749f22bc05e62f76a54668c5c30a134a9a06 Mon Sep 17 00:00:00 2001 From: Thiago Youssef <43591948+thiagoyoussef@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:18:38 -0300 Subject: [PATCH 235/318] Bump rails requestjs version --- config/importmap.rb | 2 +- vendor/javascript/@rails--request.js | 4 + vendor/javascript/rails-request.js | 308 --------------------------- 3 files changed, 5 insertions(+), 309 deletions(-) create mode 100644 vendor/javascript/@rails--request.js delete mode 100644 vendor/javascript/rails-request.js diff --git a/config/importmap.rb b/config/importmap.rb index 698815341..3431633f9 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -4,7 +4,7 @@ pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" -pin "@rails/request.js", to: "rails-request.js" # @0.0.11 +pin "@rails/request.js", to: "@rails--request.js" # @0.0.13 pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" diff --git a/vendor/javascript/@rails--request.js b/vendor/javascript/@rails--request.js new file mode 100644 index 000000000..1aa2eaee6 --- /dev/null +++ b/vendor/javascript/@rails--request.js @@ -0,0 +1,4 @@ +// @rails/request.js@0.0.13 downloaded from https://ga.jspm.io/npm:@rails/request.js@0.0.13/src/index.js + +class FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get("WWW-Authenticate")}get contentType(){const t=this.response.headers.get("Content-Type")||"";return t.replace(/;.*$/,"")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`))}get json(){return this.contentType.match(/^application\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\/vnd\.turbo-stream\.html/)}get isScript(){return this.contentType.match(/\b(?:java|ecma)script\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));{const t=document.createElement("script");const e=document.querySelector("meta[name=csp-nonce]");if(e){const n=e.nonce===""?e.content:e.nonce;n&&t.setAttribute("nonce",n)}t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function t(t){const e=document.cookie?document.cookie.split("; "):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split("=").slice(1).join("=");if(t)return decodeURIComponent(t)}}function e(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function n(t){const e=document.head.querySelector(`meta[name="${t}"]`);return e&&e.content}function s(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n==="string"?[[e,n]]:[])),[])}function r(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes("[]")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith("http:")&&!this.originalUrl.startsWith("https:"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect,keepalive:this.keepalive}}get headers(){const t={"X-Requested-With":"XMLHttpRequest","Content-Type":this.contentType,Accept:this.accept};this.sameHostname()&&(t["X-CSRF-Token"]=this.csrfToken);return e(Object.assign(t,this.additionalHeaders))}get csrfToken(){return t(n("csrf-param"))||n("csrf-token")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:"application/json"}get accept(){switch(this.responseKind){case"html":return"text/html, application/xhtml+xml";case"turbo-stream":return"text/vnd.turbo-stream.html, text/html, application/xhtml+xml";case"json":return"application/json, application/vnd.api+json";case"script":return"text/javascript, application/javascript";default:return"*/*"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split("?")[1]||"").split("#")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?s(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});r(e,n);const o=e.toString();return o.length>0?`?${o}`:""}get url(){return this.originalUrl.split("?")[0].split("#")[0]+this.query}get responseKind(){return this.options.responseKind||"html"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||"follow"}get credentials(){return this.options.credentials||"same-origin"}get keepalive(){return this.options.keepalive||false}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)==="[object String]";const e=this.headers["Content-Type"]==="application/json";return e&&!t?JSON.stringify(this.body):this.body}}async function o(t,e){const n=new FetchRequest("get",t,e);return n.perform()}async function i(t,e){const n=new FetchRequest("post",t,e);return n.perform()}async function c(t,e){const n=new FetchRequest("put",t,e);return n.perform()}async function a(t,e){const n=new FetchRequest("patch",t,e);return n.perform()}async function h(t,e){const n=new FetchRequest("delete",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,h as destroy,o as get,a as patch,i as post,c as put}; + diff --git a/vendor/javascript/rails-request.js b/vendor/javascript/rails-request.js deleted file mode 100644 index 4b8d648ce..000000000 --- a/vendor/javascript/rails-request.js +++ /dev/null @@ -1,308 +0,0 @@ -// Patched to use Turbo.fetch. @TODO Upstream this. -class FetchResponse { - constructor(response) { - this.response = response; - } - get statusCode() { - return this.response.status; - } - get redirected() { - return this.response.redirected; - } - get ok() { - return this.response.ok; - } - get unauthenticated() { - return this.statusCode === 401; - } - get unprocessableEntity() { - return this.statusCode === 422; - } - get authenticationURL() { - return this.response.headers.get("WWW-Authenticate"); - } - get contentType() { - const contentType = this.response.headers.get("Content-Type") || ""; - return contentType.replace(/;.*$/, ""); - } - get headers() { - return this.response.headers; - } - get html() { - if (this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)) { - return this.text; - } - return Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`)); - } - get json() { - if (this.contentType.match(/^application\/.*json$/)) { - return this.responseJson || (this.responseJson = this.response.json()); - } - return Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`)); - } - get text() { - return this.responseText || (this.responseText = this.response.text()); - } - get isTurboStream() { - return this.contentType.match(/^text\/vnd\.turbo-stream\.html/); - } - get isScript() { - return this.contentType.match(/\b(?:java|ecma)script\b/); - } - async renderTurboStream() { - if (this.isTurboStream) { - if (window.Turbo) { - await window.Turbo.renderStreamMessage(await this.text); - } else { - console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js"); - } - } else { - return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`)); - } - } - async activeScript() { - if (this.isScript) { - const script = document.createElement("script"); - const metaTag = document.querySelector("meta[name=csp-nonce]"); - if (metaTag) { - const nonce = metaTag.nonce === "" ? metaTag.content : metaTag.nonce; - if (nonce) { - script.setAttribute("nonce", nonce); - } - } - script.innerHTML = await this.text; - document.body.appendChild(script); - } else { - return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`)); - } - } -} - -class RequestInterceptor { - static register(interceptor) { - this.interceptor = interceptor; - } - static get() { - return this.interceptor; - } - static reset() { - this.interceptor = undefined; - } -} - -function getCookie(name) { - const cookies = document.cookie ? document.cookie.split("; ") : []; - const prefix = `${encodeURIComponent(name)}=`; - const cookie = cookies.find((cookie => cookie.startsWith(prefix))); - if (cookie) { - const value = cookie.split("=").slice(1).join("="); - if (value) { - return decodeURIComponent(value); - } - } -} - -function compact(object) { - const result = {}; - for (const key in object) { - const value = object[key]; - if (value !== undefined) { - result[key] = value; - } - } - return result; -} - -function metaContent(name) { - const element = document.head.querySelector(`meta[name="${name}"]`); - return element && element.content; -} - -function stringEntriesFromFormData(formData) { - return [ ...formData ].reduce(((entries, [name, value]) => entries.concat(typeof value === "string" ? [ [ name, value ] ] : [])), []); -} - -function mergeEntries(searchParams, entries) { - for (const [name, value] of entries) { - if (value instanceof window.File) continue; - if (searchParams.has(name) && !name.includes("[]")) { - searchParams.delete(name); - searchParams.set(name, value); - } else { - searchParams.append(name, value); - } - } -} - -class FetchRequest { - constructor(method, url, options = {}) { - this.method = method; - this.options = options; - this.originalUrl = url.toString(); - } - async perform() { - try { - const requestInterceptor = RequestInterceptor.get(); - if (requestInterceptor) { - await requestInterceptor(this); - } - } catch (error) { - console.error(error); - } - const fetch = this.responseKind === "turbo-stream" && window.Turbo ? window.Turbo.fetch : window.fetch; - const response = new FetchResponse(await Turbo.fetch(this.url, this.fetchOptions)); - if (response.unauthenticated && response.authenticationURL) { - return Promise.reject(window.location.href = response.authenticationURL); - } - if (response.isScript) { - await response.activeScript(); - } - const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity; - if (responseStatusIsTurboStreamable && response.isTurboStream) { - await response.renderTurboStream(); - } - return response; - } - addHeader(key, value) { - const headers = this.additionalHeaders; - headers[key] = value; - this.options.headers = headers; - } - sameHostname() { - if (!this.originalUrl.startsWith("http:") && !this.originalUrl.startsWith("https:")) { - return true; - } - try { - return new URL(this.originalUrl).hostname === window.location.hostname; - } catch (_) { - return true; - } - } - get fetchOptions() { - return { - method: this.method.toUpperCase(), - headers: this.headers, - body: this.formattedBody, - signal: this.signal, - credentials: this.credentials, - redirect: this.redirect, - keepalive: this.keepalive - }; - } - get headers() { - const baseHeaders = { - "X-Requested-With": "XMLHttpRequest", - "Content-Type": this.contentType, - Accept: this.accept - }; - if (this.sameHostname()) { - baseHeaders["X-CSRF-Token"] = this.csrfToken; - } - return compact(Object.assign(baseHeaders, this.additionalHeaders)); - } - get csrfToken() { - return getCookie(metaContent("csrf-param")) || metaContent("csrf-token"); - } - get contentType() { - if (this.options.contentType) { - return this.options.contentType; - } else if (this.body == null || this.body instanceof window.FormData) { - return undefined; - } else if (this.body instanceof window.File) { - return this.body.type; - } - return "application/json"; - } - get accept() { - switch (this.responseKind) { - case "html": - return "text/html, application/xhtml+xml"; - - case "turbo-stream": - return "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"; - - case "json": - return "application/json, application/vnd.api+json"; - - case "script": - return "text/javascript, application/javascript"; - - default: - return "*/*"; - } - } - get body() { - return this.options.body; - } - get query() { - const originalQuery = (this.originalUrl.split("?")[1] || "").split("#")[0]; - const params = new URLSearchParams(originalQuery); - let requestQuery = this.options.query; - if (requestQuery instanceof window.FormData) { - requestQuery = stringEntriesFromFormData(requestQuery); - } else if (requestQuery instanceof window.URLSearchParams) { - requestQuery = requestQuery.entries(); - } else { - requestQuery = Object.entries(requestQuery || {}); - } - mergeEntries(params, requestQuery); - const query = params.toString(); - return query.length > 0 ? `?${query}` : ""; - } - get url() { - return this.originalUrl.split("?")[0].split("#")[0] + this.query; - } - get responseKind() { - return this.options.responseKind || "html"; - } - get signal() { - return this.options.signal; - } - get redirect() { - return this.options.redirect || "follow"; - } - get credentials() { - return this.options.credentials || "same-origin"; - } - get keepalive() { - return this.options.keepalive || false; - } - get additionalHeaders() { - return this.options.headers || {}; - } - get formattedBody() { - const bodyIsAString = Object.prototype.toString.call(this.body) === "[object String]"; - const contentTypeIsJson = this.headers["Content-Type"] === "application/json"; - if (contentTypeIsJson && !bodyIsAString) { - return JSON.stringify(this.body); - } - return this.body; - } -} - -async function get(url, options) { - const request = new FetchRequest("get", url, options); - return request.perform(); -} - -async function post(url, options) { - const request = new FetchRequest("post", url, options); - return request.perform(); -} - -async function put(url, options) { - const request = new FetchRequest("put", url, options); - return request.perform(); -} - -async function patch(url, options) { - const request = new FetchRequest("patch", url, options); - return request.perform(); -} - -async function destroy(url, options) { - const request = new FetchRequest("delete", url, options); - return request.perform(); -} - -export { FetchRequest, FetchResponse, RequestInterceptor, destroy, get, patch, post, put }; From b0a1b370a88e49ce0f20cfa4c10d4934102087b3 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 19:55:50 -0600 Subject: [PATCH 236/318] Move transition to wrapper element --- app/assets/stylesheets/comments.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 45b5885f9..af938c6a0 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -98,8 +98,6 @@ max-inline-size: var(--comment-max); text-align: center; - transition: var(--dialog-duration) allow-discrete; - transition-property: display; .comments--system-expanded & { display: flex; @@ -160,6 +158,8 @@ .comment-wrapper--system { display: none; + transition: var(--dialog-duration) allow-discrete; + transition-property: display; .comments--system-expanded & { display: contents; From fe89debf94cc00ad52966d8ed372ad92ea05afef Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 19:57:19 -0600 Subject: [PATCH 237/318] No longer used --- app/assets/stylesheets/comments.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index af938c6a0..5b368bafa 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -99,10 +99,6 @@ max-inline-size: var(--comment-max); text-align: center; - .comments--system-expanded & { - display: flex; - } - &::before { /* Make up space for lack of avatar */ content: ""; From ae5cb096f91e9f8948acc3c4b36adae9defdb264 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 20:06:38 -0600 Subject: [PATCH 238/318] Let's just use the class we already have --- app/assets/stylesheets/comments.css | 122 ++++++++++----------- app/views/cards/comments/_comment.html.erb | 4 +- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 5b368bafa..1d070a992 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -35,6 +35,65 @@ .house-md-content { padding: var(--comment-padding-block) 0 0; } + + .comment--system & { + --comment-padding-block: var(--block-space-half); + + max-inline-size: var(--comment-max); + text-align: center; + + &::before { + /* Make up space for lack of avatar */ + content: ""; + display: flex; + inline-size: calc(var(--comment-padding-inline) * 0.75); + } + + .comment__avatar { + display: none; + } + + .comment__author { + a { + margin: 0 auto; + } + + h3 { + margin-inline: auto; + } + + strong { + display: none; + } + } + + .comment__body { + padding: 0; + text-align: center; + } + + .comment__content { + --stripe-color: var(--color-ink-lightest); + + background-image: repeating-linear-gradient( + 45deg in srgb, + var(--color-canvas) 0 1px, + var(--stripe-color) 1px 10px); + padding-inline: var(--comment-padding-inline); + + .comments--system-expanded .comment--system & { + --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); + } + + .comment__history { + background-color: var(--stripe-color); + } + } + + .reactions { + display: none !important; + } + } } .comment__author { @@ -94,65 +153,6 @@ } .comment--system { - --comment-padding-block: var(--block-space-half); - - max-inline-size: var(--comment-max); - text-align: center; - - &::before { - /* Make up space for lack of avatar */ - content: ""; - display: flex; - inline-size: calc(var(--comment-padding-inline) * 0.75); - } - - .comment__avatar { - display: none; - } - - .comment__author { - a { - margin: 0 auto; - } - - h3 { - margin-inline: auto; - } - - strong { - display: none; - } - } - - .comment__body { - padding: 0; - text-align: center; - } - - .comment__content { - --stripe-color: var(--color-ink-lightest); - - background-image: repeating-linear-gradient( - 45deg in srgb, - var(--color-canvas) 0 1px, - var(--stripe-color) 1px 10px); - padding-inline: var(--comment-padding-inline); - - .comments--system-expanded .comment--system & { - --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); - } - - .comment__history { - background-color: var(--stripe-color); - } - } - - .reactions { - display: none !important; - } - } - - .comment-wrapper--system { display: none; transition: var(--dialog-duration) allow-discrete; transition-property: display; @@ -163,7 +163,7 @@ } /* Show the last system comment */ - :nth-last-child(1 of .comment-wrapper--system) { + :nth-last-child(1 of .comment--system) { display: contents; .comment__history { @@ -172,7 +172,7 @@ } /* Hide the "Show history" button if there's only one system comment */ - :nth-child(1 of .comment-wrapper--system) { + :nth-child(1 of .comment--system) { .comment__history { display: none; } diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index c7f0a2480..2f8a120a0 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -1,8 +1,8 @@ <% cache comment do %> <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> - <%= turbo_frame_tag comment, :container, class: ["comment-wrapper", { "comment-wrapper--system": comment.creator.system? }] do %> + <%= turbo_frame_tag comment, :container, class: ["comment--system": comment.creator.system?] do %> <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%> -
"> +
From 7c92d48d8a8b602bdc162757b1140f330eb2c85a Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 20:10:06 -0600 Subject: [PATCH 239/318] Style --- app/assets/stylesheets/comments.css | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 1d070a992..9e5094d15 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -54,17 +54,9 @@ } .comment__author { - a { - margin: 0 auto; - } - - h3 { - margin-inline: auto; - } - - strong { - display: none; - } + a { margin: 0 auto; } + h3 { margin-inline: auto; } + strong { display: none; } } .comment__body { From ec979ab3ff5149e7057c55bb5e9446fca17d5c72 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 20:15:59 -0600 Subject: [PATCH 240/318] Style --- app/assets/stylesheets/steps.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/steps.css index c339118ae..b9b40c8fa 100644 --- a/app/assets/stylesheets/steps.css +++ b/app/assets/stylesheets/steps.css @@ -94,6 +94,7 @@ .steps__icon { --icon-size: 0.875em; + background-color: var(--card-color); block-size: 1.3em; border-radius: 50%; From f9b7d05f2c305f5749b0415462aa727917f39c15 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 20:35:34 -0600 Subject: [PATCH 241/318] Fix that comments and system comments weren't the same width on mobile --- app/assets/stylesheets/comments.css | 4 ++-- app/assets/stylesheets/reactions.css | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 9e5094d15..c3dbb7d74 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -39,14 +39,13 @@ .comment--system & { --comment-padding-block: var(--block-space-half); - max-inline-size: var(--comment-max); text-align: center; &::before { /* Make up space for lack of avatar */ content: ""; display: flex; - inline-size: calc(var(--comment-padding-inline) * 0.75); + inline-size: calc(var(--comment-padding-inline) * 0.9); } .comment__avatar { @@ -125,6 +124,7 @@ background-color: var(--comment-bg-color); border-radius: 0.2em; + max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75)); padding: var(--comment-padding-block) calc(var(--comment-padding-inline) / 2) diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index 797e9a7cb..5dea448cc 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -24,6 +24,10 @@ margin: 0; position: absolute; + @media (max-width: 640px) { + inset-inline-end: calc(var(--comment-padding-inline) / 3); + } + .reactions__list { display: none; } From 389b817bb4ffaa9af282b471b81f46c7be95231f Mon Sep 17 00:00:00 2001 From: Adrien Maston Date: Thu, 18 Dec 2025 08:31:15 +0100 Subject: [PATCH 242/318] Replace android and ios css layers with platform layer --- app/assets/stylesheets/_global.css | 2 +- app/assets/stylesheets/android.css | 2 +- app/assets/stylesheets/ios.css | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 05c92341a..30e5da88a 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -1,4 +1,4 @@ -@layer reset, base, components, modules, utilities, native, android, ios; +@layer reset, base, components, modules, utilities, native, platform; :root { /* Spacing */ diff --git a/app/assets/stylesheets/android.css b/app/assets/stylesheets/android.css index aab43d79b..f30158e8e 100644 --- a/app/assets/stylesheets/android.css +++ b/app/assets/stylesheets/android.css @@ -1,4 +1,4 @@ -@layer android { +@layer platform { [data-platform~=android] { .hide-on-android { display: none; diff --git a/app/assets/stylesheets/ios.css b/app/assets/stylesheets/ios.css index c5dfc50be..3a05e5956 100644 --- a/app/assets/stylesheets/ios.css +++ b/app/assets/stylesheets/ios.css @@ -1,4 +1,4 @@ -@layer ios { +@layer platform { [data-platform~=ios] { .hide-on-ios { display: none; From 611e1cc97e8fffa06b3b26fe11b50533902e1225 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:44:11 +0100 Subject: [PATCH 243/318] Add additional plan for extra storage --- saas/.kamal/secrets.beta | 3 ++- saas/.kamal/secrets.production | 3 ++- saas/.kamal/secrets.staging | 3 ++- saas/app/models/plan.rb | 7 ++++++- saas/exe/stripe-dev | 5 ++++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 0fb3db660..658b75c59 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 672359a13..768abbcd0 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 7121f5e3a..e98540135 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 7ef7f922e..826601bfd 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,8 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] } + monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }, + monthly_extra_storage_v1: { name: "Unlimited with extra storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id @@ -19,6 +20,10 @@ class Plan @paid ||= find(:monthly_v1) end + def paid_with_extra_storage + @paid_with_extra_storage ||= find(:monthly_extra_storage_v1) + end + def find(key) @all_by_key ||= all.index_by(&:key).with_indifferent_access @all_by_key[key] diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index 2033385ff..5f4820cc7 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -30,13 +30,15 @@ secrets_escaped = `kamal secrets fetch \ --account basecamp \ --from "Deploy/Fizzy" \ "Development/STRIPE_SECRET_KEY" \ - "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` + "Development/STRIPE_MONTHLY_V1_PRICE_ID" \ + "Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID" 2>/dev/null` secrets_json = secrets_escaped.gsub(/\\(.)/, '\1') secrets = JSON.parse(secrets_json) stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"] stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"] +stripe_extra_storage_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] # Clear previous log file File.write(LOG_FILE, "") @@ -72,6 +74,7 @@ end # Output export statements puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}") puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}") +puts %Q(export STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID="#{stripe_extra_storage_price_id}") puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret # Informational message to stderr (won't be eval'd) From a1a67c65f5ece494a906741f4ae27aee43c5dcf4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:45:11 +0100 Subject: [PATCH 244/318] Extract partials --- saas/app/helpers/subscriptions_helper.rb | 4 ++-- .../account/settings/_free_plan.html.erb | 12 +++++++++++ .../account/settings/_paid_plan.html.erb | 6 ++++++ .../settings/_subscription_panel.html.erb | 21 ++----------------- 4 files changed, 22 insertions(+), 21 deletions(-) create mode 100644 saas/app/views/account/settings/_free_plan.html.erb create mode 100644 saas/app/views/account/settings/_paid_plan.html.erb diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index a1c4c07d9..89d14b74b 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -1,6 +1,6 @@ module SubscriptionsHelper - def plan_storage_limit(plan) - number_to_human_size(plan.storage_limit).delete(" ") + def storage_to_human_size(bytes) + number_to_human_size(bytes).delete(" ") end def subscription_period_end_action(subscription) diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb new file mode 100644 index 000000000..093aa960b --- /dev/null +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -0,0 +1,12 @@ +<% if Current.account.exceeding_card_limit? %> +

You've used up your <%= Plan.free.card_limit %> free cards

+<% else %> +

You've used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

+<% end %> + +

If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+ +<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> + +

Cancel anytime, no contracts, take your data with you whenever.

+ diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb new file mode 100644 index 000000000..c49af1016 --- /dev/null +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -0,0 +1,6 @@ +

Thank you for buying Fizzy

+ +<% if Current.account.subscription %> + <%= render "account/settings/subscription", subscription: Current.account.subscription %> + +<% end %> diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 44949e022..5a5f0eace 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -3,26 +3,9 @@

Subscription

<% if Current.account.plan.free? %> - <% if Current.account.exceeding_card_limit? %> -

You’ve used up your <%= Plan.free.card_limit %> free cards

- <% else %> -

You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

- <% end %> - -

If you’d like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it’s only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= plan_storage_limit(Plan.paid) %> of storage.

- - <%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> - -

Cancel anytime, no contracts, take your data with you whenever.

- + <%= render "account/settings/free_plan" %> <% else %> -

Thank you for buying Fizzy

- - <% if Current.account.subscription %> - <%= render "account/settings/subscription", subscription: Current.account.subscription %> - - <% end %> + <%= render "account/settings/paid_plan" %> <% end %> - <% end %> From 8205823588e4ee099df7681349949ee8fc7ccbef Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:46:27 +0100 Subject: [PATCH 245/318] Add button to buy extra-storage subscriptions --- .../account/subscriptions_controller.rb | 10 +++++++--- .../app/views/account/settings/_free_plan.html.erb | 1 + .../accounts/subscriptions_controller_test.rb | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 8ddfb4b7a..bbc4ec47a 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -9,10 +9,10 @@ class Account::SubscriptionsController < ApplicationController session = Stripe::Checkout::Session.create \ customer: find_or_create_stripe_customer, mode: "subscription", - line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ], + line_items: [ { price: plan_param.stripe_price_id, quantity: 1 } ], success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", cancel_url: account_subscription_url, - metadata: { account_id: Current.account.id, plan_key: Plan.paid.key }, + metadata: { account_id: Current.account.id, plan_key: plan_param.key }, automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, billing_address_collection: "required", @@ -22,6 +22,10 @@ class Account::SubscriptionsController < ApplicationController end private + def plan_param + @plan_param ||= Plan[params[:plan_key]] || Plan.paid + end + def set_stripe_session @stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] end @@ -36,7 +40,7 @@ class Account::SubscriptionsController < ApplicationController def create_stripe_customer Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer| - Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete") + Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: plan_param.key, status: "incomplete") end end end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index 093aa960b..4785de354 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -7,6 +7,7 @@

If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> +<%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for $#{ Plan.paid_with_extra_storage.price }/month", account_subscription_path(plan_key: Plan.paid_with_extra_storage.key), class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel anytime, no contracts, take your data with you whenever.

diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb index d98ac1d91..9e086df39 100644 --- a/saas/test/controllers/accounts/subscriptions_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -43,4 +43,18 @@ class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest post account_subscription_path assert_response :forbidden end + + test "create with custom plan_key redirects to stripe checkout" do + customer = OpenStruct.new(id: "cus_test_37signals") + session = OpenStruct.new(url: "https://checkout.stripe.com/session123") + + Stripe::Customer.stubs(:retrieve).returns(customer) + Stripe::Checkout::Session.stubs(:create).with do |params| + params[:metadata][:plan_key] == :monthly_extra_storage_v1 + end.returns(session) + + post account_subscription_path(plan_key: :monthly_extra_storage_v1) + + assert_redirected_to "https://checkout.stripe.com/session123" + end end From 823cf9fbb9d212ffab150b1ad7e3f0944f525907 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:48:39 +0100 Subject: [PATCH 246/318] Add some methods to deal with storage limits --- .../controllers/admin/accounts_controller.rb | 6 +-- saas/app/models/account/limited.rb | 29 ++++++++++++- saas/app/views/admin/accounts/edit.html.erb | 17 +++++--- ...bytes_used_to_account_overridden_limits.rb | 5 +++ saas/db/saas_schema.rb | 3 +- saas/test/models/account/limited_test.rb | 41 +++++++++++++++++++ 6 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb index ba6200d1b..26a2b9c82 100644 --- a/saas/app/controllers/admin/accounts_controller.rb +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -12,7 +12,7 @@ class Admin::AccountsController < AdminController end def update - @account.override_limits(card_count: overridden_card_count_param) + @account.override_limits(**overridden_limits_params.to_h.symbolize_keys) redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated" end @@ -21,7 +21,7 @@ class Admin::AccountsController < AdminController @account = Account.find_by!(external_account_id: params[:id]) end - def overridden_card_count_param - params[:account][:overridden_card_count].to_i + def overridden_limits_params + params.expect(account: [ :card_count, :bytes_used ]) end end diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index f7eb3cbc1..b185815e4 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -6,15 +6,20 @@ module Account::Limited end NEAR_CARD_LIMIT_THRESHOLD = 100 + NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes - def override_limits(card_count:) - (overridden_limits || build_overridden_limits).update!(card_count:) + def override_limits(card_count: nil, bytes_used: nil) + (overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:) end def billed_cards_count overridden_limits&.card_count || cards_count end + def billed_bytes_used + overridden_limits&.bytes_used || bytes_used + end + def nearing_plan_cards_limit? plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD end @@ -23,6 +28,22 @@ module Account::Limited plan.limit_cards? && billed_cards_count > plan.card_limit end + def nearing_plan_storage_limit? + remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD + end + + def exceeding_storage_limit? + billed_bytes_used > plan.storage_limit + end + + def nearing_limits? + nearing_plan_cards_limit? || nearing_plan_storage_limit? + end + + def exceeding_limits? + exceeding_card_limit? || exceeding_storage_limit? + end + def reset_overridden_limits overridden_limits&.destroy reload_overridden_limits @@ -32,4 +53,8 @@ module Account::Limited def remaining_cards_count plan.card_limit - billed_cards_count end + + def remaining_storage + plan.storage_limit - billed_bytes_used + end end diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 49c99773d..950ecc761 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -11,14 +11,21 @@
Actual card count:
<%= @account.cards_count %>
+
+
Actual bytes used:
+
<%= storage_to_human_size(@account.bytes_used) %>
+
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %> -
- <%= form.label :overridden_card_count, "Override card count", class: "font-weight-bold" %> -
- <%= form.number_field :overridden_card_count, value: @account.overridden_limits&.card_count, class: "input full-width" %> -
+
+ <%= form.label :card_count, "Override card count", class: "font-weight-bold" %> + <%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %> +
+ +
+ <%= form.label :bytes_used, "Override bytes used", class: "font-weight-bold" %> + <%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
diff --git a/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb new file mode 100644 index 000000000..85adad44a --- /dev/null +++ b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb @@ -0,0 +1,5 @@ +class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2] + def change + add_column :account_overridden_limits, :bytes_used, :bigint + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 59c78f4cb..7e9b80b92 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do +ActiveRecord::Schema[8.2].define(version: 2025_12_16_000000) do create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false @@ -20,6 +20,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do create_table "account_overridden_limits", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false + t.bigint "bytes_used" t.integer "card_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index 335d6a37c..dafca0c31 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -73,4 +73,45 @@ class Account::LimitedTest < ActiveSupport::TestCase assert account.exceeding_card_limit? end + + test "detect nearing storage limit" do + # Paid plans have large storage limits + accounts(:"37s").update_column(:bytes_used, 4.gigabytes) + assert_not accounts(:"37s").nearing_plan_storage_limit? + + # Free plan not near limit + accounts(:initech).update_column(:bytes_used, 400.megabytes) + assert_not accounts(:initech).nearing_plan_storage_limit? + + # Free plan near limit + accounts(:initech).update_column(:bytes_used, 600.megabytes) + assert accounts(:initech).nearing_plan_storage_limit? + end + + test "detect exceeding storage limit" do + # Free plan under limit + accounts(:initech).update_column(:bytes_used, 900.megabytes) + assert_not accounts(:initech).exceeding_storage_limit? + + # Free plan over limit + accounts(:initech).update_column(:bytes_used, 1.1.gigabytes) + assert accounts(:initech).exceeding_storage_limit? + end + + test "override bytes_used limits" do + account = accounts(:initech) + account.update_column(:bytes_used, 1.1.gigabytes) + + assert account.exceeding_storage_limit? + assert_equal 1.1.gigabytes, account.billed_bytes_used + + account.override_limits bytes_used: 500.megabytes + assert_not account.exceeding_storage_limit? + assert_equal 500.megabytes, account.billed_bytes_used + assert_equal 1.1.gigabytes, account.bytes_used # original unchanged + + account.reset_overridden_limits + assert account.exceeding_storage_limit? + assert_equal 1.1.gigabytes, account.billed_bytes_used + end end From fc2848ea89218a8f235cfaac11c0c27ed6219ebc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:50:36 +0100 Subject: [PATCH 247/318] Initial storage notices --- .../account/subscriptions/_upgrade.html.erb | 11 ++++------- .../_admin_exceeding_card_limit.html.erb | 4 ++++ .../_admin_exceeding_storage_limit.html.erb | 4 ++++ .../_user_exceeding_card_limit.html.erb | 3 +++ .../_user_exceeding_storage_limit.html.erb | 3 +++ .../container/footer/saas/_create.html.erb | 2 +- .../footer/saas/_near_notice.html.erb | 18 +++++++++--------- .../notices/_admin_nearing_card_limit.html.erb | 1 + .../_admin_nearing_storage_limit.html.erb | 1 + .../notices/_user_nearing_card_limit.html.erb | 1 + .../_user_nearing_storage_limit.html.erb | 1 + 11 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb create mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb create mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb create mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index d1c06e7f0..449fecf16 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,12 +1,9 @@
<% if Current.user.admin? %> - You’ve used your <%= Plan.free.card_limit %> free cards. - <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade to Unlimited - <% end %> + <%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> <% else %> -
- This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. -
+ <%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> <% end %>
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb new file mode 100644 index 000000000..e6c714ed4 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb @@ -0,0 +1,4 @@ +You've used your <%= Plan.free.card_limit %> free cards. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade to Unlimited +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb new file mode 100644 index 000000000..9f7c6c370 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb @@ -0,0 +1,4 @@ +You've used your <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade for more storage +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb new file mode 100644 index 000000000..f3f65f3be --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb @@ -0,0 +1,3 @@ +
+ This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb new file mode 100644 index 000000000..51d4a5cdd --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb @@ -0,0 +1,3 @@ +
+ This account has used <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade to get more. +
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb index 295c9bd19..8b0edd308 100644 --- a/saas/app/views/cards/container/footer/saas/_create.html.erb +++ b/saas/app/views/cards/container/footer/saas/_create.html.erb @@ -1,4 +1,4 @@ -<% if Current.account.exceeding_card_limit? %> +<% if Current.account.exceeding_limits? %> <%= render "account/subscriptions/upgrade" %> <% else %> <%= render "cards/container/footer/create", card: card %> diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb index e40aa9fb5..8dc739e67 100644 --- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -1,9 +1,9 @@ -<% if Current.account.nearing_plan_cards_limit? %> -
- <% if Current.user.admin? %> - You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. - <% else %> - This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon - <% end %> -
-<% end %> +
+ <% if Current.user.admin? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <% else %> + <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> + <%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <% end %> +
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb new file mode 100644 index 000000000..1b303e62b --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb @@ -0,0 +1 @@ +You've used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb new file mode 100644 index 000000000..904d29657 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb @@ -0,0 +1 @@ +You've used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. <%= link_to "Upgrade for more", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb new file mode 100644 index 000000000..30f7b5f14 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb @@ -0,0 +1 @@ +This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb new file mode 100644 index 000000000..c9df3e874 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb @@ -0,0 +1 @@ +This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade soon From 61c2277362a189adb6244b027189ae242f3dffbc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:51:47 +0100 Subject: [PATCH 248/318] Place nearing variant above for consistency --- .../views/cards/container/footer/saas/_near_notice.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb index 8dc739e67..4cf480f3c 100644 --- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -1,9 +1,9 @@
<% if Current.user.admin? %> - <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <% else %> - <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <% end %>
From 9c8c56cdb83e9b1038b8cac81330926caff6200e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:59:47 +0100 Subject: [PATCH 249/318] Comped plans use the extra storage variant --- saas/app/models/account/billing_waiver.rb | 2 +- saas/app/models/account/subscription.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb index c1968522a..e539f32d6 100644 --- a/saas/app/models/account/billing_waiver.rb +++ b/saas/app/models/account/billing_waiver.rb @@ -2,6 +2,6 @@ class Account::BillingWaiver < SaasRecord belongs_to :account def subscription - @subscription ||= Account::Subscription.new(plan_key: Plan.paid.key) + @subscription ||= Account::Subscription.new(plan_key: Plan.paid_with_extra_storage) end end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 0eac71b02..dd7833dfc 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -11,6 +11,10 @@ class Account::Subscription < SaasRecord @plan ||= Plan.find(plan_key) end + def plan=(plan) + self.plan_key = plan.key + end + def to_be_canceled? active? && cancel_at.present? end From c56dbb5f1e851592ed1247d2588d5f44feed20d7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 10:02:23 +0100 Subject: [PATCH 250/318] Add upgrade/downgrade options --- .../subscriptions/downgrades_controller.rb | 28 ++++++++++++++++ .../subscriptions/upgrades_controller.rb | 28 ++++++++++++++++ .../controllers/stripe/webhooks_controller.rb | 8 ++++- saas/app/models/account/billing_waiver.rb | 2 +- .../account/settings/_subscription.html.erb | 10 +++++- saas/lib/fizzy/saas/engine.rb | 9 +++-- .../downgrades_controller_test.rb | 33 +++++++++++++++++++ .../subscriptions/upgrades_controller_test.rb | 33 +++++++++++++++++++ .../admin/accounts_controller_test.rb | 2 +- saas/test/models/account/limited_test.rb | 12 +++---- 10 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 saas/app/controllers/account/subscriptions/downgrades_controller.rb create mode 100644 saas/app/controllers/account/subscriptions/upgrades_controller.rb create mode 100644 saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb create mode 100644 saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb new file mode 100644 index 000000000..565e0d2ac --- /dev/null +++ b/saas/app/controllers/account/subscriptions/downgrades_controller.rb @@ -0,0 +1,28 @@ +class Account::Subscriptions::DowngradesController < ApplicationController + before_action :ensure_admin + + def create + portal_session = Stripe::BillingPortal::Session.create( + customer: subscription.stripe_customer_id, + return_url: account_settings_url(anchor: "subscription"), + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: subscription.stripe_subscription_id, + items: [ { id: stripe_subscription_item_id, price: Plan.paid.stripe_price_id } ] + } + } + ) + + redirect_to portal_session.url, allow_other_host: true + end + + private + def subscription + @subscription ||= Current.account.subscription + end + + def stripe_subscription_item_id + Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + end +end diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb new file mode 100644 index 000000000..97a112329 --- /dev/null +++ b/saas/app/controllers/account/subscriptions/upgrades_controller.rb @@ -0,0 +1,28 @@ +class Account::Subscriptions::UpgradesController < ApplicationController + before_action :ensure_admin + + def create + portal_session = Stripe::BillingPortal::Session.create( + customer: subscription.stripe_customer_id, + return_url: account_settings_url(anchor: "subscription"), + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: subscription.stripe_subscription_id, + items: [ { id: stripe_subscription_item_id, price: Plan.paid_with_extra_storage.stripe_price_id } ] + } + } + ) + + redirect_to portal_session.url, allow_other_host: true + end + + private + def subscription + @subscription ||= Current.account.subscription + end + + def stripe_subscription_item_id + Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + end +end diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index bf00fc23f..74627203b 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -49,7 +49,8 @@ class Stripe::WebhooksController < ApplicationController status: stripe_subscription.status, current_period_end: current_period_end_for(stripe_subscription), cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil, - next_amount_due_in_cents: next_amount_due_for(stripe_subscription) + next_amount_due_in_cents: next_amount_due_for(stripe_subscription), + plan_key: plan_key_for(stripe_subscription) } yield subscription_properties if block_given? @@ -76,4 +77,9 @@ class Stripe::WebhooksController < ApplicationController rescue Stripe::InvalidRequestError nil end + + def plan_key_for(stripe_subscription) + price_id = stripe_subscription.items.data.first&.price&.id + Plan.all.find { |plan| plan.stripe_price_id == price_id }&.key + end end diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb index e539f32d6..a9ec35eb8 100644 --- a/saas/app/models/account/billing_waiver.rb +++ b/saas/app/models/account/billing_waiver.rb @@ -2,6 +2,6 @@ class Account::BillingWaiver < SaasRecord belongs_to :account def subscription - @subscription ||= Account::Subscription.new(plan_key: Plan.paid_with_extra_storage) + @subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage) end end diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 3aef9b853..8a3e922bf 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -4,4 +4,12 @@

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

<% end %> -<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> +
+ <%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> + + <% if subscription.plan == Plan.paid %> + <%= button_to "Upgrade to Extra Storage", account_subscription_upgrade_path, class: "btn", form: { data: { turbo: false } } %> + <% elsif subscription.plan == Plan.paid_with_extra_storage %> + <%= button_to "Downgrade to Regular", account_subscription_downgrade_path, class: "btn", form: { data: { turbo: false } } %> + <% end %> +
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 532f0a384..757de6beb 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -10,7 +10,7 @@ module Fizzy Queenbee.host_app = Fizzy initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app| - app.config.x.content_security_policy.form_action = "https://checkout.stripe.com" + app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com" end initializer "fizzy_saas.assets" do |app| @@ -22,7 +22,12 @@ module Fizzy app.routes.prepend do namespace :account do resource :billing_portal, only: :show - resource :subscription + resource :subscription do + scope module: :subscriptions do + resource :upgrade, only: :create + resource :downgrade, only: :create + end + end end namespace :stripe do diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb new file mode 100644 index 000000000..acfeedad3 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" +require "ostruct" + +class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + accounts(:"37s").subscription.update!( + stripe_subscription_id: "sub_123", + plan_key: Plan.paid_with_extra_storage.key + ) + end + + test "downgrade redirects to stripe billing portal" do + stripe_subscription = OpenStruct.new( + items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) + ) + portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") + + Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) + Stripe::BillingPortal::Session.stubs(:create).returns(portal_session) + + post account_subscription_downgrade_path + + assert_redirected_to "https://billing.stripe.com/session/abc123" + end + + test "downgrade requires admin" do + logout_and_sign_in_as :david + + post account_subscription_downgrade_path + assert_response :forbidden + end +end diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb new file mode 100644 index 000000000..049de5efb --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" +require "ostruct" + +class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + accounts(:"37s").subscription.update!( + stripe_subscription_id: "sub_123", + plan_key: Plan.paid.key + ) + end + + test "upgrade redirects to stripe billing portal" do + stripe_subscription = OpenStruct.new( + items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) + ) + portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") + + Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) + Stripe::BillingPortal::Session.stubs(:create).returns(portal_session) + + post account_subscription_upgrade_path + + assert_redirected_to "https://billing.stripe.com/session/abc123" + end + + test "upgrade requires admin" do + logout_and_sign_in_as :david + + post account_subscription_upgrade_path + assert_response :forbidden + end +end diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb index 0b830919a..3849c07a5 100644 --- a/saas/test/controllers/admin/accounts_controller_test.rb +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -34,7 +34,7 @@ class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest sign_in_as :david untenanted do - patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } } + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { card_count: 500 } } assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) end diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index dafca0c31..828386023 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -76,31 +76,31 @@ class Account::LimitedTest < ActiveSupport::TestCase test "detect nearing storage limit" do # Paid plans have large storage limits - accounts(:"37s").update_column(:bytes_used, 4.gigabytes) + accounts(:"37s").stubs(:bytes_used).returns(4.gigabytes) assert_not accounts(:"37s").nearing_plan_storage_limit? # Free plan not near limit - accounts(:initech).update_column(:bytes_used, 400.megabytes) + accounts(:initech).stubs(:bytes_used).returns(400.megabytes) assert_not accounts(:initech).nearing_plan_storage_limit? # Free plan near limit - accounts(:initech).update_column(:bytes_used, 600.megabytes) + accounts(:initech).stubs(:bytes_used).returns(600.megabytes) assert accounts(:initech).nearing_plan_storage_limit? end test "detect exceeding storage limit" do # Free plan under limit - accounts(:initech).update_column(:bytes_used, 900.megabytes) + accounts(:initech).stubs(:bytes_used).returns(900.megabytes) assert_not accounts(:initech).exceeding_storage_limit? # Free plan over limit - accounts(:initech).update_column(:bytes_used, 1.1.gigabytes) + accounts(:initech).stubs(:bytes_used).returns(1.1.gigabytes) assert accounts(:initech).exceeding_storage_limit? end test "override bytes_used limits" do account = accounts(:initech) - account.update_column(:bytes_used, 1.1.gigabytes) + account.stubs(:bytes_used).returns(1.1.gigabytes) assert account.exceeding_storage_limit? assert_equal 1.1.gigabytes, account.billed_bytes_used From 68cec744367d9c8582ad55905a0973ee92741f55 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 10:51:51 +0100 Subject: [PATCH 251/318] Extract parent class, validate plans can be downgraded/upgraded --- .../subscriptions/downgrades_controller.rb | 28 ++++------------ .../subscriptions/update_plan_controller.rb | 32 +++++++++++++++++++ .../subscriptions/upgrades_controller.rb | 28 ++++------------ .../downgrades_controller_test.rb | 9 +++++- .../subscriptions/upgrades_controller_test.rb | 9 +++++- 5 files changed, 60 insertions(+), 46 deletions(-) create mode 100644 saas/app/controllers/account/subscriptions/update_plan_controller.rb diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb index 565e0d2ac..d06795841 100644 --- a/saas/app/controllers/account/subscriptions/downgrades_controller.rb +++ b/saas/app/controllers/account/subscriptions/downgrades_controller.rb @@ -1,28 +1,12 @@ -class Account::Subscriptions::DowngradesController < ApplicationController - before_action :ensure_admin - - def create - portal_session = Stripe::BillingPortal::Session.create( - customer: subscription.stripe_customer_id, - return_url: account_settings_url(anchor: "subscription"), - flow_data: { - type: "subscription_update_confirm", - subscription_update_confirm: { - subscription: subscription.stripe_subscription_id, - items: [ { id: stripe_subscription_item_id, price: Plan.paid.stripe_price_id } ] - } - } - ) - - redirect_to portal_session.url, allow_other_host: true - end +class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController + before_action :ensure_downgradeable private - def subscription - @subscription ||= Current.account.subscription + def target_plan + Plan.paid end - def stripe_subscription_item_id - Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + def ensure_downgradeable + head :bad_request unless subscription.plan == Plan.paid_with_extra_storage end end diff --git a/saas/app/controllers/account/subscriptions/update_plan_controller.rb b/saas/app/controllers/account/subscriptions/update_plan_controller.rb new file mode 100644 index 000000000..c6a665cec --- /dev/null +++ b/saas/app/controllers/account/subscriptions/update_plan_controller.rb @@ -0,0 +1,32 @@ +class Account::Subscriptions::UpdatePlanController < ApplicationController + before_action :ensure_admin + + def create + portal_session = Stripe::BillingPortal::Session.create( + customer: subscription.stripe_customer_id, + return_url: account_settings_url(anchor: "subscription"), + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: subscription.stripe_subscription_id, + items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ] + } + } + ) + + redirect_to portal_session.url, allow_other_host: true + end + + private + def target_plan + raise NotImplementedError + end + + def subscription + @subscription ||= Current.account.subscription + end + + def stripe_subscription_item_id + Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + end +end diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb index 97a112329..1de6d8c6c 100644 --- a/saas/app/controllers/account/subscriptions/upgrades_controller.rb +++ b/saas/app/controllers/account/subscriptions/upgrades_controller.rb @@ -1,28 +1,12 @@ -class Account::Subscriptions::UpgradesController < ApplicationController - before_action :ensure_admin - - def create - portal_session = Stripe::BillingPortal::Session.create( - customer: subscription.stripe_customer_id, - return_url: account_settings_url(anchor: "subscription"), - flow_data: { - type: "subscription_update_confirm", - subscription_update_confirm: { - subscription: subscription.stripe_subscription_id, - items: [ { id: stripe_subscription_item_id, price: Plan.paid_with_extra_storage.stripe_price_id } ] - } - } - ) - - redirect_to portal_session.url, allow_other_host: true - end +class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController + before_action :ensure_upgradeable private - def subscription - @subscription ||= Current.account.subscription + def target_plan + Plan.paid_with_extra_storage end - def stripe_subscription_item_id - Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + def ensure_upgradeable + head :bad_request unless subscription.plan == Plan.paid end end diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb index acfeedad3..e425806c4 100644 --- a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -6,7 +6,7 @@ class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::Integra sign_in_as :kevin accounts(:"37s").subscription.update!( stripe_subscription_id: "sub_123", - plan_key: Plan.paid_with_extra_storage.key + plan: Plan.paid_with_extra_storage ) end @@ -30,4 +30,11 @@ class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::Integra post account_subscription_downgrade_path assert_response :forbidden end + + test "downgrade requires downgradeable plan" do + accounts(:"37s").subscription.update!(plan: Plan.paid) + + post account_subscription_downgrade_path + assert_response :bad_request + end end diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb index 049de5efb..87308842c 100644 --- a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -6,7 +6,7 @@ class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::Integrati sign_in_as :kevin accounts(:"37s").subscription.update!( stripe_subscription_id: "sub_123", - plan_key: Plan.paid.key + plan: Plan.paid ) end @@ -30,4 +30,11 @@ class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::Integrati post account_subscription_upgrade_path assert_response :forbidden end + + test "upgrade requires upgradeable plan" do + accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage) + + post account_subscription_upgrade_path + assert_response :bad_request + end end From 4e6274d59ac0c941c162ef0e035057db08584ff5 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 11:21:53 +0100 Subject: [PATCH 252/318] Consider storage when preventing backend card creation and publishing too --- saas/app/controllers/concerns/card/limited.rb | 8 ++++++++ .../controllers/concerns/card/limited_creation.rb | 9 +++------ .../controllers/concerns/card/limited_publishing.rb | 9 +++------ saas/test/controllers/card/limited_creation_test.rb | 12 ++++++++++++ .../test/controllers/card/limited_publishing_test.rb | 11 +++++++++++ 5 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 saas/app/controllers/concerns/card/limited.rb diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb new file mode 100644 index 000000000..0576c6c4e --- /dev/null +++ b/saas/app/controllers/concerns/card/limited.rb @@ -0,0 +1,8 @@ +module Card::Limited + extend ActiveSupport::Concern + + private + def ensure_under_limits + head :forbidden if Current.account.exceeding_limits? + end +end diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb index b4c773449..0e992008a 100644 --- a/saas/app/controllers/concerns/card/limited_creation.rb +++ b/saas/app/controllers/concerns/card/limited_creation.rb @@ -2,13 +2,10 @@ module Card::LimitedCreation extend ActiveSupport::Concern included do + include Card::Limited + # Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count. # We limit card publications separately. See +Card::LimitedPublishing+. - before_action :ensure_can_create_cards, only: %i[ create ], if: -> { request.format.json? } + before_action :ensure_under_limits, only: %i[ create ], if: -> { request.format.json? } end - - private - def ensure_can_create_cards - head :forbidden if Current.account.exceeding_card_limit? - end end diff --git a/saas/app/controllers/concerns/card/limited_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb index b92ab06f3..8b60a4b53 100644 --- a/saas/app/controllers/concerns/card/limited_publishing.rb +++ b/saas/app/controllers/concerns/card/limited_publishing.rb @@ -2,11 +2,8 @@ module Card::LimitedPublishing extend ActiveSupport::Concern included do - before_action :ensure_can_publish_cards, only: %i[ create ] - end + include Card::Limited - private - def ensure_can_publish_cards - head :forbidden if Current.account.exceeding_card_limit? - end + before_action :ensure_under_limits, only: %i[ create ] + end end diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb index b3477735c..4105091be 100644 --- a/saas/test/controllers/card/limited_creation_test.rb +++ b/saas/test/controllers/card/limited_creation_test.rb @@ -40,4 +40,16 @@ class Card::LimitedCreationTest < ActionDispatch::IntegrationTest assert_response :redirect assert Card.last.drafted? end + + test "cannot create cards via JSON when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug, format: :json) + end + + assert_response :forbidden + end end diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb index 2eb57471e..90e4e5696 100644 --- a/saas/test/controllers/card/limited_publishing_test.rb +++ b/saas/test/controllers/card/limited_publishing_test.rb @@ -11,4 +11,15 @@ class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest assert_response :forbidden assert cards(:unfinished_thoughts).reload.drafted? end + + test "cannot publish cards when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :forbidden + assert cards(:unfinished_thoughts).reload.drafted? + end end From 00cab8ad7aa6ee3b736e9dc17346c01934b2438a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 11:39:01 +0100 Subject: [PATCH 253/318] Add tests for subscription-related messaging --- .../subscriptions/card_creation_test.rb | 79 +++++++++++++++++++ .../accounts/subscriptions/settings_test.rb | 63 +++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 saas/test/controllers/accounts/subscriptions/card_creation_test.rb create mode 100644 saas/test/controllers/accounts/subscriptions/settings_test.rb diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb new file mode 100644 index 000000000..650ca7a1a --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest + # Nearing limits - shown in card creation footer + + test "admin sees nearing card limit notice" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 950) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /upgrade to unlimited/i, response.body + end + + test "admin sees nearing storage limit notice" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(600.megabytes) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /upgrade for more/i, response.body + end + + # Exceeding limits - shown instead of create buttons + + test "admin sees exceeding card limit notice" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /you've used your.*free cards/i, response.body + end + + test "admin sees exceeding storage limit notice" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /you've used your.*free storage/i, response.body + end + + # Paid accounts under limits - no notices + + test "paid account under limits sees no notices" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get card_path(cards(:layout), script_name: accounts(:"37s").slug) + + assert_response :success + assert_no_match /upgrade/i, response.body + assert_no_match /you've used your/i, response.body + end + + # Comped accounts under limits - no notices + + test "comped account under limits sees no notices" do + sign_in_as :mike + + accounts(:initech).comp + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_no_match /upgrade/i, response.body + assert_no_match /you've used your/i, response.body + end +end diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb new file mode 100644 index 000000000..702e1e4fe --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/settings_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest + test "free users see current usage" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 3) + + get account_settings_path(script_name: accounts(:initech).slug) + + assert_response :success + assert_select "h3", text: /You've used 3 free cards out of 1000/ + end + + test "paid users see thank you message" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "h3", text: "Thank you for buying Fizzy" + end + + test "regular plan users see upgrade option" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "button", text: /upgrade/i + assert_select "button", text: /downgrade/i, count: 0 + end + + test "extra storage plan users see downgrade option" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "button", text: /downgrade/i + assert_select "button", text: /upgrade/i, count: 0 + end + + test "comped accounts see no subscription panel" do + sign_in_as :mike + + accounts(:initech).comp + + get account_settings_path(script_name: accounts(:initech).slug) + + assert_response :success + assert_no_match /thank you for buying/i, response.body + assert_no_match /free cards out of/i, response.body + assert_no_match /upgrade/i, response.body + assert_no_match /downgrade/i, response.body + end +end From 3b99aa5dcf792661d4ac80acb2f4ff4a7f295283 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 11:53:09 +0100 Subject: [PATCH 254/318] Remove unused method --- saas/app/models/account/limited.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index b185815e4..8e846a117 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -36,10 +36,6 @@ module Account::Limited billed_bytes_used > plan.storage_limit end - def nearing_limits? - nearing_plan_cards_limit? || nearing_plan_storage_limit? - end - def exceeding_limits? exceeding_card_limit? || exceeding_storage_limit? end From b326f78d028fa7de897fc49af3554c64a5d0eaf8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 00:03:13 +0100 Subject: [PATCH 255/318] More idiomatic --- saas/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 826601bfd..6a27be07f 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -50,6 +50,6 @@ class Plan end def limit_cards? - card_limit != Float::INFINITY + !card_limit.infinite? end end From 5f4330cbcc7e402c9c10ecb4070a7b66e2ef8c7f Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 14:02:52 -0600 Subject: [PATCH 256/318] Update free plan partial to include storage limits --- .../views/account/settings/_free_plan.html.erb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index 4785de354..548fa9a40 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -1,13 +1,16 @@ -<% if Current.account.exceeding_card_limit? %> -

You've used up your <%= Plan.free.card_limit %> free cards

+<% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> +

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage

+<% elsif Current.account.exceeding_card_limit? %> +

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards

+<% elsif Current.account.exceeding_storage_limit? %> +

You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage

<% else %> -

You've used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

+

You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>

<% end %> -

If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only $<%= Plan.paid.price %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> -<%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for $#{ Plan.paid_with_extra_storage.price }/month", account_subscription_path(plan_key: Plan.paid_with_extra_storage.key), class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel anytime, no contracts, take your data with you whenever.

- + From 95bfc40392b0c045c4a1284a6cfc7783065797e6 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 15:38:01 -0600 Subject: [PATCH 257/318] Tweak plan name --- saas/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 6a27be07f..a15ecab54 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -2,7 +2,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }, - monthly_extra_storage_v1: { name: "Unlimited with extra storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } + monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id From cfd2033d15d40423363a988982349abdcc5f9cdc Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:09:39 -0600 Subject: [PATCH 258/318] Design paid plan screen --- saas/app/assets/stylesheets/fizzy/saas.css | 4 +++ saas/app/helpers/subscriptions_helper.rb | 2 +- .../account/settings/_free_plan.html.erb | 24 +++++++++-------- .../account/settings/_paid_plan.html.erb | 27 ++++++++++++++++--- .../account/settings/_subscription.html.erb | 20 +++++++------- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css index 539f79b32..7afd852ff 100644 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -31,6 +31,10 @@ } } + .settings-subscription__link { + color: var(--settings-subscription-text-color); + } + .settings-subscription__notch { animation: wiggle 500ms ease; background: var(--settings-subscription-background); diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index 89d14b74b..48ce0372f 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -9,7 +9,7 @@ module SubscriptionsHelper elsif subscription.canceled? "Your Fizzy subscription ended on" else - "Your next payment of #{ number_to_currency(subscription.next_amount_due) } will be billed on".html_safe + "Your next payment is #{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) } on".html_safe end end end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index 548fa9a40..1f59ff49f 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -1,16 +1,18 @@ -<% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> -

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage

-<% elsif Current.account.exceeding_card_limit? %> -

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards

-<% elsif Current.account.exceeding_storage_limit? %> -

You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage

-<% else %> -

You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>

-<% end %> +

+ <% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage + <% elsif Current.account.exceeding_card_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards + <% elsif Current.account.exceeding_storage_limit? %> + You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage + <% else %> + You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %> + <% end %> +

-

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only $<%= Plan.paid.price %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

-<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> +<%= button_to "Upgrade to #{Plan.paid.name} for #{ number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel anytime, no contracts, take your data with you whenever.

diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb index c49af1016..c2e09221b 100644 --- a/saas/app/views/account/settings/_paid_plan.html.erb +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -1,6 +1,27 @@ -

Thank you for buying Fizzy

+

+ <% if Current.account.exceeding_storage_limit? %> + You’ve run out of storage + <% elsif Current.account.nearing_plan_storage_limit? %> + You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> of storage + <% else %> + Thank you for buying Fizzy + <% end %> +

+ +<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %> +

+ The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %>. Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more. +

+<% end %> <% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - -<% end %> + +<% end %> \ No newline at end of file diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 8a3e922bf..70bd03ab6 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -1,15 +1,17 @@ - - <% if subscription.current_period_end %>

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

<% end %> -
- <%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> - - <% if subscription.plan == Plan.paid %> - <%= button_to "Upgrade to Extra Storage", account_subscription_upgrade_path, class: "btn", form: { data: { turbo: false } } %> - <% elsif subscription.plan == Plan.paid_with_extra_storage %> +

+ <% if subscription.plan == Plan.paid && (Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit?) %> + <%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name } for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button", form: { data: { turbo: false } } %> + <% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %> <%= button_to "Downgrade to Regular", account_subscription_downgrade_path, class: "btn", form: { data: { turbo: false } } %> <% end %> -

+

+ +

+ <%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %> +

+ + From b1a65bd698f64763cd00887b7ad1d4a32779e205 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:23:56 -0600 Subject: [PATCH 259/318] Consistency --- saas/app/views/account/settings/_subscription.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 70bd03ab6..68e3acd65 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -1,5 +1,5 @@ <% if subscription.current_period_end %> -

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

+

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

<% end %>

From d8b3485812762cef5df1fe7ed487d45bc4809868 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:40:28 -0600 Subject: [PATCH 260/318] Copy edits for limit notices --- .../notices/_admin_exceeding_card_limit.html.erb | 2 +- .../notices/_admin_exceeding_storage_limit.html.erb | 4 ++-- .../notices/_user_exceeding_storage_limit.html.erb | 2 +- .../footer/saas/notices/_admin_nearing_card_limit.html.erb | 2 +- .../footer/saas/notices/_admin_nearing_storage_limit.html.erb | 2 +- .../footer/saas/notices/_user_nearing_card_limit.html.erb | 2 +- .../footer/saas/notices/_user_nearing_storage_limit.html.erb | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb index e6c714ed4..3dc759d9e 100644 --- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb @@ -1,4 +1,4 @@ -You've used your <%= Plan.free.card_limit %> free cards. +You’ve used your <%= Plan.free.card_limit %> free cards. <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> Upgrade to Unlimited <% end %> diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb index 9f7c6c370..8770c1460 100644 --- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb @@ -1,4 +1,4 @@ -You've used your <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. +You’ve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade for more storage + Upgrade to get more <% end %> diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb index 51d4a5cdd..f230443a1 100644 --- a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb @@ -1,3 +1,3 @@

- This account has used <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade to get more. + This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more.
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb index 1b303e62b..caa7bd958 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb @@ -1 +1 @@ -You've used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. +You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb index 904d29657..ab1a3ca34 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb @@ -1 +1 @@ -You've used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. <%= link_to "Upgrade for more", account_settings_path(anchor: "subscription") %>. +You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. <%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb index 30f7b5f14..8ab80e162 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb @@ -1 +1 @@ -This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon +This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb index c9df3e874..25428980a 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb @@ -1 +1 @@ -This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade soon +This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade soon. From 8bb59f507b4224751e61f61a2d17cfe721baafea Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:49:39 -0600 Subject: [PATCH 261/318] Cherry-pick changes in b87ae1494a8c002a7f7d54d2c2d25f6a2ce2da8d --- saas/app/assets/stylesheets/fizzy/saas.css | 1 - 1 file changed, 1 deletion(-) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css index 7afd852ff..326b95b40 100644 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -43,7 +43,6 @@ color: var(--settings-subscription-text-color); margin-inline: auto; padding: 0 0.3em 0 1.2em; - padding: 0.5ch 0.5ch 0.5ch 2ch; transform: rotate(-1deg); @media (max-width: 640px) { From 0f9d1df31b4bfb25aea655f69e735a4948c90b07 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 09:21:19 +0100 Subject: [PATCH 262/318] Format --- .../accounts/subscriptions/downgrades_controller_test.rb | 9 ++------- .../accounts/subscriptions/upgrades_controller_test.rb | 9 ++------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb index e425806c4..e4680a1f8 100644 --- a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -4,16 +4,11 @@ require "ostruct" class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin - accounts(:"37s").subscription.update!( - stripe_subscription_id: "sub_123", - plan: Plan.paid_with_extra_storage - ) + accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid_with_extra_storage) end test "downgrade redirects to stripe billing portal" do - stripe_subscription = OpenStruct.new( - items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) - ) + stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ])) portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb index 87308842c..65e268af1 100644 --- a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -4,16 +4,11 @@ require "ostruct" class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin - accounts(:"37s").subscription.update!( - stripe_subscription_id: "sub_123", - plan: Plan.paid - ) + accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid) end test "upgrade redirects to stripe billing portal" do - stripe_subscription = OpenStruct.new( - items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) - ) + stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ])) portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) From 7bf701095cebd9c9f01a7b7e7f662bc342bd4c99 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 09:35:00 +0100 Subject: [PATCH 263/318] Fix tests after the UI/copy changes --- .../accounts/subscriptions/card_creation_test.rb | 10 +++++----- .../accounts/subscriptions/settings_test.rb | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb index 650ca7a1a..3f24c82c5 100644 --- a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb +++ b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb @@ -22,7 +22,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success - assert_match /upgrade for more/i, response.body + assert_match /upgrade to get more/i, response.body end # Exceeding limits - shown instead of create buttons @@ -35,7 +35,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success - assert_match /you've used your.*free cards/i, response.body + assert_match /you’ve used your.*free cards/i, response.body end test "admin sees exceeding storage limit notice" do @@ -46,7 +46,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success - assert_match /you've used your.*free storage/i, response.body + assert_match /you’ve run out of.*free storage/i, response.body end # Paid accounts under limits - no notices @@ -60,7 +60,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest assert_response :success assert_no_match /upgrade/i, response.body - assert_no_match /you've used your/i, response.body + assert_no_match /you’ve used your/i, response.body end # Comped accounts under limits - no notices @@ -74,6 +74,6 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest assert_response :success assert_no_match /upgrade/i, response.body - assert_no_match /you've used your/i, response.body + assert_no_match /you’ve used your/i, response.body end end diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb index 702e1e4fe..63feac2c5 100644 --- a/saas/test/controllers/accounts/subscriptions/settings_test.rb +++ b/saas/test/controllers/accounts/subscriptions/settings_test.rb @@ -9,7 +9,7 @@ class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest get account_settings_path(script_name: accounts(:initech).slug) assert_response :success - assert_select "h3", text: /You've used 3 free cards out of 1000/ + assert_match /You’ve used.*3.*free cards out of 1,000/i, response.body end test "paid users see thank you message" do @@ -44,7 +44,6 @@ class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest assert_response :success assert_select "button", text: /downgrade/i - assert_select "button", text: /upgrade/i, count: 0 end test "comped accounts see no subscription panel" do From d9ce1577c05bf54a03792a2ff37c176e455ad28b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 09:47:06 +0100 Subject: [PATCH 264/318] Fix upgrade/downgrade buttons - Remove old buttons I had added before the UI work. - Fix: make new upgrade button use the dedicated action. - Add symmetric downgrade button. --- .../account/settings/_paid_plan.html.erb | 10 +--------- .../account/settings/_subscription.html.erb | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb index c2e09221b..426568eae 100644 --- a/saas/app/views/account/settings/_paid_plan.html.erb +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -16,12 +16,4 @@ <% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - -<% end %> \ No newline at end of file +<% end %> diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 68e3acd65..5ea251c8d 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -2,16 +2,19 @@

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

<% end %> -

- <% if subscription.plan == Plan.paid && (Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit?) %> - <%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name } for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button", form: { data: { turbo: false } } %> - <% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %> - <%= button_to "Downgrade to Regular", account_subscription_downgrade_path, class: "btn", form: { data: { turbo: false } } %> - <% end %> -

-

<%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %>

+ From b44b7ba992a59b7e3f5ec2ddf3f17ecc976eadb8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 10:02:06 +0100 Subject: [PATCH 265/318] Add method to locate plans by the stripe price id --- saas/app/controllers/stripe/webhooks_controller.rb | 2 +- saas/app/models/plan.rb | 4 ++++ saas/test/models/plan_test.rb | 7 +++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 74627203b..cdfb6bcec 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -80,6 +80,6 @@ class Stripe::WebhooksController < ApplicationController def plan_key_for(stripe_subscription) price_id = stripe_subscription.items.data.first&.price&.id - Plan.all.find { |plan| plan.stripe_price_id == price_id }&.key + Plan.find_by_price_id(price_id)&.key end end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index a15ecab54..e77ea81e8 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -29,6 +29,10 @@ class Plan @all_by_key[key] end + def find_by_price_id(price_id) + all.find { |plan| plan.stripe_price_id == price_id } + end + alias [] find end diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb index 6e0d70b5c..54ed279d6 100644 --- a/saas/test/models/plan_test.rb +++ b/saas/test/models/plan_test.rb @@ -8,4 +8,11 @@ class PlanTest < ActiveSupport::TestCase test "monthly plan is not free" do assert_not Plan[:monthly_v1].free? end + + test "find plan by its price id" do + Plan.paid.stubs(:stripe_price_id).returns("price_monthly_v1") + + assert_equal Plan.paid, Plan.find_by_price_id("price_monthly_v1") + assert_nil Plan.find_by_price_id("unknown_price_id") + end end From b2017a75cf6ce159801787025d61af208f2afc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Mon, 15 Dec 2025 14:20:17 +0000 Subject: [PATCH 266/318] Add script to separate non-blank sibling

--- .../split-sibling-paragraphs-with-p-br.rb | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 script/migrations/split-sibling-paragraphs-with-p-br.rb diff --git a/script/migrations/split-sibling-paragraphs-with-p-br.rb b/script/migrations/split-sibling-paragraphs-with-p-br.rb new file mode 100644 index 000000000..f3e8954d6 --- /dev/null +++ b/script/migrations/split-sibling-paragraphs-with-p-br.rb @@ -0,0 +1,124 @@ +#!/usr/bin/env ruby + +BACKFILL_TIMESTAMP = Time.parse("2025-12-18 00:00:00 UTC") +ACCOUNT_ID = nil # restrict to an account_id + +# Split sibling

tags with content by inserting

to replicaate previous view. +# Run for the time range before paragraphs were not spaced +# See https://app.fizzy.do/5986089/cards/3472 +# and https://github.com/basecamp/fizzy/pull/2107 +# +# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption +# +# Run locally: +# bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb" +# +# Safe to re-run for a time range: won't re-detect unsplit paragraphs and updated_at will be outside time window +class SeparateSiblingParagraphs + attr_reader :updated_at, :account_id + + def initialize(updated_at, account_id: nil) + @updated_at = updated_at + @account_id = account_id + end + + def run + puts "Separating non-blank sibling paragraphs" + + puts "Updated at: #{updated_at}" + puts account_id ? "Only account id: #{account_id}" : "For **ALL ACCOUNTS**" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + # Suppress SQL logs + Rails.event.debug_mode = false + + seconds = Benchmark.realtime do + suppressing_turbo_broadcasts do + separate_nonblank_paragraphs + end + end + + puts "\n\n" + puts "Finished splitting non-blank

s in %.2f seconds." % seconds + end + + private + def suppressing_turbo_broadcasts + Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + yield + end + end + end + + def separate_nonblank_paragraphs + scanned = 0 + fixed = 0 + insertions = 0 + + action_texts_scope.find_each(**batch_options) do |rich_text| + next if account_id && rich_text.record.account.external_account_id != account_id + + scanned += 1 + edited = false + + rich_text.body&.fragment.tap do |fragment| + next unless fragment + + fragment.find_all("p + p").each do |node| + unless ineligible?(node) || ineligible?(node.previous_sibling) + node.add_previous_sibling empty_node_markup + edited = true + insertions += 1 + end + end + + if edited + puts " - modifying #{rich_text.record.class.name} #{rich_text.record.to_param} (account: #{rich_text.record.account.external_account_id})" unless demo_card?(rich_text.record) + # allow implicit touching to invalidate caches + rich_text.update! body: fragment.to_html + fixed +=1 + end + end + end + + puts "\n\Separation complete!" + puts " Rich texts examined: #{scanned}" + puts " Rich texts modified: #{fixed}" + puts " Paragraphs inserted: #{insertions}" + fixed + end + + def action_texts_scope + ActionText::RichText.where(updated_at: updated_at) + end + + def batch_options + { batch_size: 20, order: :desc } + end + + def ineligible?(node) + empty_node?(node) || wrapping_attachment?(node) + end + + def empty_node?(node) + node.to_html == empty_node_markup + end + + def empty_node_markup + "


" + end + + def wrapping_attachment?(node) + node.children.first&.name == "action-text-attachment" + end +end + +SeparateSiblingParagraphs.new(..BACKFILL_TIMESTAMP, account_id: ACCOUNT_ID).run From d250c256c65b44102fbecf67917995eb8650e596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 18 Dec 2025 00:24:46 +0000 Subject: [PATCH 267/318] Add paragraph space to seed Card #8 --- app/models/account/seeder.rb | 1 + script/migrations/split-sibling-paragraphs-with-p-br.rb | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/app/models/account/seeder.rb b/app/models/account/seeder.rb index 4a349b939..ff97538be 100644 --- a/app/models/account/seeder.rb +++ b/app/models/account/seeder.rb @@ -69,6 +69,7 @@ class Account::Seeder
  • Make another called "Working on"
  • Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.

    +


    After that, drag this card to “DONE” or select “DONE” in the sidebar.

    HTML diff --git a/script/migrations/split-sibling-paragraphs-with-p-br.rb b/script/migrations/split-sibling-paragraphs-with-p-br.rb index f3e8954d6..42778292f 100644 --- a/script/migrations/split-sibling-paragraphs-with-p-br.rb +++ b/script/migrations/split-sibling-paragraphs-with-p-br.rb @@ -119,6 +119,10 @@ class SeparateSiblingParagraphs def wrapping_attachment?(node) node.children.first&.name == "action-text-attachment" end + + def demo_card?(record) + record.is_a?(Card) && record.number <= 8 + end end SeparateSiblingParagraphs.new(..BACKFILL_TIMESTAMP, account_id: ACCOUNT_ID).run From fd89a452c3e2b1088982e417118ca0055b6e351d Mon Sep 17 00:00:00 2001 From: Italo Matos Date: Thu, 18 Dec 2025 07:09:57 -0300 Subject: [PATCH 268/318] Optimize card count queries to avoid duplicates Replace duplicate .count calls with cached variables using .size method. This reduces database queries by approximately 50% on mobile board views. Changes: - Cache card counts in local variables (postponed, awaiting_triage, closed, active) - Use .size instead of .count (more intelligent - leverages preload when available) - Reuse cached values instead of making duplicate SQL queries Impact: - Mobile columns view: reduced from ~6 to ~3 COUNT queries - Better performance with no visual changes to the UI --- .../boards/show/_mobile_columns.html.erb | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/views/boards/show/_mobile_columns.html.erb b/app/views/boards/show/_mobile_columns.html.erb index 964d75220..b52e14592 100644 --- a/app/views/boards/show/_mobile_columns.html.erb +++ b/app/views/boards/show/_mobile_columns.html.erb @@ -1,11 +1,15 @@ +<% postponed_count = board.cards.postponed.size %> +<% awaiting_triage_count = board.cards.awaiting_triage.size %> +<% closed_count = board.cards.closed.size %> +
    <%= render "columns/show/add_card_button", board: board %>
    <%= link_to board_columns_not_now_path(board), class: "cards cards--on-deck is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %> -
    - <%= board.cards.postponed.count %> +
    + <%= postponed_count %>

    Not Now

    @@ -13,8 +17,8 @@ <% end %> <%= link_to board_columns_stream_path(board), class: "cards cards--considering is-collapsed", data: { turbo_frame: "_top" } do %> -
    - <%= board.cards.awaiting_triage.count %> +
    + <%= awaiting_triage_count %>

    Maybe?

    @@ -22,9 +26,10 @@ <% end %> <% board.columns.sorted.each do |column| %> + <% active_count = column.cards.active.size %> <%= link_to board_column_path(column.board, column), class: "cards cards--doing is-collapsed", style: "--card-color: #{column.color}", data: { turbo_frame: "_top" } do %> -
    - <%= column.cards.active.count %> +
    + <%= active_count %>

    <%= column.name %>

    @@ -33,8 +38,8 @@ <% end %> <%= link_to board_columns_closed_path(board), class: "cards cards--closed is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %> -
    - <%= board.cards.closed.count %> +
    + <%= closed_count %>

    Done

    From 440b8a7dbf8018132ba53e5e283f49f7fff2d7a6 Mon Sep 17 00:00:00 2001 From: Francisco Merino-Casallo Date: Thu, 18 Dec 2025 11:30:59 +0100 Subject: [PATCH 269/318] Clarify S3 storage wording in Docker deployment guide --- docs/docker-deployment.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index f92b80ca2..b83067522 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -114,7 +114,7 @@ Set those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variable #### S3 storage (optional) -If you're rather that uploaded files were stored in an S3 bucket, rather than your mounted volume, you can set that up. +If you'd prefer that uploaded files were stored in an S3 bucket rather than in your mounted volume, you can set that up. First set `ACTIVE_STORAGE_SERVICE` to `s3`. Then set the following as appropriate for your S3 bucket: From dd59771c94ee1924def5d8dea8a1cc7245673c10 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 18 Dec 2025 07:25:20 -0600 Subject: [PATCH 270/318] Use current plan because you might be on the extra storage plan --- saas/app/views/account/settings/_subscription.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 5ea251c8d..c3e629607 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -7,7 +7,7 @@

    -