From db57a7ba0f45e8c27c2761c9af010cce2f808905 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:04:20 +0100 Subject: [PATCH 001/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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/274] 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 38d0e362aa14c759dff276619856ec33316afde4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 24 Nov 2025 15:16:17 +0100 Subject: [PATCH 017/274] 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 018/274] 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 019/274] 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 020/274] 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 021/274] 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 022/274] 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 023/274] 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 024/274] 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 025/274] 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 026/274] 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 027/274] 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 028/274] 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 cb76bf10e2d58d6fc1199659cce8d09efa07bb3f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 26 Nov 2025 13:00:09 +0100 Subject: [PATCH 029/274] 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 030/274] 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 031/274] 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 032/274] 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 033/274] 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 034/274] 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 035/274] 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 036/274] 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 037/274] 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 038/274] 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 039/274] 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 040/274] 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 041/274] 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 042/274] 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 043/274] 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 044/274] 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 045/274] 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 046/274] 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 047/274] 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 048/274] 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 049/274] 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 050/274] 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 051/274] 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 052/274] 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 053/274] 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 054/274] 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 055/274] 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 056/274] 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 057/274] 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 058/274] 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 059/274] 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 060/274] 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 061/274] 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 062/274] 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 063/274] 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 064/274] 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 065/274] 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 066/274] 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 067/274] 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 068/274] 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 069/274] 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 070/274] 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 071/274] 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 072/274] 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 073/274] 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 074/274] 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 075/274] 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 076/274] 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 077/274] 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 078/274] 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 079/274] 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 080/274] 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 081/274] 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 082/274] 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 083/274] 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 084/274] 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 219f4d60b6a9cba33d9310b412d71e711f060ad5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 10 Dec 2025 15:01:32 -0800 Subject: [PATCH 085/274] 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 086/274] 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 087/274] 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 03ea5f7b89cf433eedd04b2b61e8172d16befd9c Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 11:38:28 +0000 Subject: [PATCH 088/274] 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 089/274] 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 090/274] 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 091/274] 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 d82d68fcd0cb5432e6d8b5138dc5ab8c7d1998c9 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 14:47:18 -0500 Subject: [PATCH 092/274] 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 acc90ec8eb016a85e866d1907e2a230e3fd9b212 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 15:24:30 -0500 Subject: [PATCH 093/274] 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 6050b80fdd54734d0ad6b643190a686201f23627 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 13 Dec 2025 10:32:18 -0800 Subject: [PATCH 094/274] 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 cd4fbc011ca4df8ce6cdad41dee47105d4149580 Mon Sep 17 00:00:00 2001 From: Italo Matos Date: Sun, 14 Dec 2025 14:22:06 -0300 Subject: [PATCH 095/274] 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 f01a648441adb1f7c09a6f60ce5a9c2111d92a16 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 15 Dec 2025 15:59:25 +0800 Subject: [PATCH 096/274] =?UTF-8?q?=F0=9F=90=9B=20fix:=20resolve=20stale?= =?UTF-8?q?=20account=20names=20in=20jump=20menu=20and=20page=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add account data to fresh_when etag arrays so Rails serves fresh responses after account changes. - Add @accounts to menus controller etag - Add Current.account to boards controller etag - Add ETag cache invalidation tests for both controllers --- app/controllers/boards_controller.rb | 2 +- app/controllers/my/menus_controller.rb | 3 +- app/views/my/menus/show.html.erb | 2 +- test/controllers/boards_controller_test.rb | 10 +++ test/controllers/my/menus_controller_test.rb | 81 ++++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 test/controllers/my/menus_controller_test.rb diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 6c8a17575..721e69b0d 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -83,7 +83,7 @@ class BoardsController < ApplicationController def show_columns cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded set_page_and_extract_portion_from cards - fresh_when etag: [ @board, @page.records, @user_filtering ] + fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ] end def board_params diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index 7a5754593..32b3da004 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -4,7 +4,8 @@ class My::MenusController < ApplicationController @boards = Current.user.boards.ordered_by_recently_accessed @tags = Current.account.tags.all.alphabetically @users = Current.account.users.active.alphabetically + @accounts = Current.identity.accounts - fresh_when etag: [ @filters, @boards, @tags, @users ] + fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ] end end diff --git a/app/views/my/menus/show.html.erb b/app/views/my/menus/show.html.erb index e6ee838e2..cb3e6310e 100644 --- a/app/views/my/menus/show.html.erb +++ b/app/views/my/menus/show.html.erb @@ -5,7 +5,7 @@ <%= render "my/menus/people", users: @users %> <%= render "my/menus/settings" %> <%= render "my/menus/shortcuts" %> - <%= render "my/menus/accounts", accounts: Current.identity.accounts %> + <%= render "my/menus/accounts", accounts: @accounts %> <% end %>
diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 7669881cb..094cca56b 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -15,6 +15,16 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "invalidates page title cache when account updates" do + get board_path(boards(:writebook)) + etag = response.headers["ETag"] + + accounts("37s").update!(name: "Renamed Account") + + get board_path(boards(:writebook)), headers: { "If-None-Match" => etag } + assert_response :success + end + test "create" do assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "Remodel Punch List" } } diff --git a/test/controllers/my/menus_controller_test.rb b/test/controllers/my/menus_controller_test.rb new file mode 100644 index 000000000..bbd560764 --- /dev/null +++ b/test/controllers/my/menus_controller_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class My::MenusControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + @user = users(:kevin) + @account = accounts("37s") + end + + test "show" do + get my_menu_path + assert_response :success + end + + test "etag invalidates when filters change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @user.filters.create!( + params_digest: Filter.digest_params({ indexed_by: :all, sorted_by: :newest }), + fields: { indexed_by: :all, sorted_by: :newest } + ) + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when boards change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @account.boards.create!(name: "New Board", all_access: true, creator: @user) + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when tags change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @account.tags.create!(title: "new-tag") + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when users change" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @user.touch + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag invalidates when account changes" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + @account.update!(name: "Renamed Account") + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :success + end + + test "etag returns not modified when nothing changes" do + get my_menu_path + assert_response :success + etag = response.headers["ETag"] + + get my_menu_path, headers: { "If-None-Match" => etag } + assert_response :not_modified + end +end From 15daa2bf05f1f42920c233bda26d66d3059ddf49 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 15 Dec 2025 15:26:22 +0100 Subject: [PATCH 097/274] Limit asignees to 10 per card --- app/models/assignment.rb | 11 +++++++++ app/models/card/assignable.rb | 8 ++++--- test/models/assignment_test.rb | 37 ++++++++++++++++++++++++++--- test/models/card/assignable_test.rb | 27 +++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/models/assignment.rb b/app/models/assignment.rb index f60eb974d..02f81bc25 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -1,7 +1,18 @@ class Assignment < ApplicationRecord + LIMIT = 10 + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :assignee, class_name: "User" belongs_to :assigner, class_name: "User" + + validate :within_limit, on: :create + + private + def within_limit + if card.assignments.count >= LIMIT + errors.add(:base, "Card already has the maximum of #{LIMIT} assignees") + end + end end diff --git a/app/models/card/assignable.rb b/app/models/card/assignable.rb index be0dd257e..6320faeb3 100644 --- a/app/models/card/assignable.rb +++ b/app/models/card/assignable.rb @@ -24,10 +24,12 @@ module Card::Assignable private def assign(user) - assignments.create! assignee: user, assigner: Current.user - watch_by user + assignment = assignments.create assignee: user, assigner: Current.user - track_event :assigned, assignee_ids: [ user.id ] + if assignment.persisted? + watch_by user + track_event :assigned, assignee_ids: [ user.id ] + end rescue ActiveRecord::RecordNotUnique # Already assigned end diff --git a/test/models/assignment_test.rb b/test/models/assignment_test.rb index ca8f09a34..3ac309619 100644 --- a/test/models/assignment_test.rb +++ b/test/models/assignment_test.rb @@ -1,7 +1,38 @@ require "test_helper" class AssignmentTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "create" do + card = cards(:text) + assignment = card.assignments.create!(assignee: users(:david), assigner: users(:jason)) + + assert_equal users(:david), assignment.assignee + assert_equal users(:jason), assignment.assigner + assert_equal card, assignment.card + end + + test "create cannot exceed assignee limit" do + card = cards(:logo) + board = card.board + account = card.account + + card.assignments.delete_all + + Assignment::LIMIT.times do |i| + identity = Identity.create!(email_address: "limit_test_#{i}@example.com") + user = account.users.create!(identity: identity, name: "Limit Test User #{i}", role: :member) + user.accesses.find_or_create_by!(board: board) + card.assignments.create!(assignee: user, assigner: users(:david)) + end + + assert_equal Assignment::LIMIT, card.assignments.count + + identity = Identity.create!(email_address: "over_limit@example.com") + extra_user = account.users.create!(identity: identity, name: "Over Limit User", role: :member) + extra_user.accesses.find_or_create_by!(board: board) + + assignment = card.assignments.build(assignee: extra_user, assigner: users(:david)) + + assert_not assignment.valid? + assert_includes assignment.errors[:base], "Card already has the maximum of #{Assignment::LIMIT} assignees" + end end diff --git a/test/models/card/assignable_test.rb b/test/models/card/assignable_test.rb index 9d0c1f1c5..6ee8a164d 100644 --- a/test/models/card/assignable_test.rb +++ b/test/models/card/assignable_test.rb @@ -12,4 +12,31 @@ class Card::AssignableTest < ActiveSupport::TestCase assert cards(:layout).assigned_to?(users(:kevin)) assert cards(:layout).watched_by?(users(:kevin)) end + + test "toggle_assignment does not add assignee when at limit" do + card = cards(:logo) + board = card.board + account = card.account + + card.assignments.delete_all + + Assignment::LIMIT.times do |i| + identity = Identity.create!(email_address: "toggle_test_#{i}@example.com") + user = account.users.create!(identity: identity, name: "Toggle Test User #{i}", role: :member) + user.accesses.find_or_create_by!(board: board) + card.assignments.create!(assignee: user, assigner: users(:david)) + end + + identity = Identity.create!(email_address: "toggle_over@example.com") + extra_user = account.users.create!(identity: identity, name: "Toggle Over User", role: :member) + extra_user.accesses.find_or_create_by!(board: board) + + with_current_user(:david) do + assert_no_difference "card.assignments.count" do + card.toggle_assignment(extra_user) + end + end + + assert_not card.reload.assigned_to?(extra_user) + end end From 15cb1f7fe19896b1caf01889eb9a1d36a1d608a1 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 15 Dec 2025 16:00:11 +0100 Subject: [PATCH 098/274] Add UI to prevent assigning more than 10 asignees --- .../cards/assignments_controller.rb | 15 +++++++---- .../assignment_limit_controller.js | 26 +++++++++++++++++++ app/views/cards/assignments/new.html.erb | 15 ++++++++--- 3 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 app/javascript/controllers/assignment_limit_controller.js diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb index 396fd3a5f..3e9fe1d48 100644 --- a/app/controllers/cards/assignments_controller.rb +++ b/app/controllers/cards/assignments_controller.rb @@ -8,11 +8,16 @@ class Cards::AssignmentsController < ApplicationController end def create - @card.toggle_assignment @board.users.active.find(params[:assignee_id]) - - respond_to do |format| - format.turbo_stream - format.json { head :no_content } + if @card.toggle_assignment @board.users.active.find(params[:assignee_id]) + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end + else + respond_to do |format| + format.turbo_stream + format.json { head :unprocessable_entity } + end end end end diff --git a/app/javascript/controllers/assignment_limit_controller.js b/app/javascript/controllers/assignment_limit_controller.js new file mode 100644 index 000000000..06f630c59 --- /dev/null +++ b/app/javascript/controllers/assignment_limit_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { limit: Number, count: Number } + static targets = ["unassigned", "limitMessage"] + + connect() { + this.updateState() + } + + countValueChanged() { + this.updateState() + } + + updateState() { + const atLimit = this.countValue >= this.limitValue + + this.unassignedTargets.forEach(el => { + el.hidden = atLimit + }) + + if (this.hasLimitMessageTarget) { + this.limitMessageTarget.hidden = !atLimit + } + } +} diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index 6692a53f0..9267875cc 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -1,10 +1,12 @@ <%= turbo_frame_tag @card, :assignment do %> <%= tag.div class: "max-width full-width", data: { action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset", - controller: "filter navigable-list", + controller: "filter navigable-list assignment-limit", dialog_target: "dialog", navigable_list_focus_on_selection_value: false, - navigable_list_actionable_items_value: true } do %> + navigable_list_actionable_items_value: true, + assignment_limit_limit_value: Assignment::LIMIT, + assignment_limit_count_value: @card.assignments.count } do %>
Assign this to… @@ -19,7 +21,14 @@ <%= Current.user.name %> <% end %> <%= render collection: @assigned_to, partial: "user", locals: { card: @card } %> - <%= render collection: @users, partial: "user", locals: { card: @card } %> + + <% @users.each do |user| %> + + <%= render "user", card: @card, user: user %> + + <% end %> <% end %> <% end %> From 140399aa6c3e265a0fa33d329d75b08d47606821 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 15 Dec 2025 16:49:54 -0600 Subject: [PATCH 099/274] Style max notice --- app/assets/stylesheets/popup.css | 9 +++++++++ app/views/cards/assignments/new.html.erb | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index 0213d04cb..189cfef5b 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -36,6 +36,15 @@ } } + .popup__footer { + border-block-start: 1px solid var(--color-ink-lightest); + color: var(--card-color); + margin-block-start: var(--popup-item-padding-inline); + padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0; + text-align: center; + text-transform: initial; + } + .popup__title { font-weight: 800; white-space: nowrap; diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index 9267875cc..dc6fe1166 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -21,14 +21,15 @@ <%= Current.user.name %> <% end %> <%= render collection: @assigned_to, partial: "user", locals: { card: @card } %> - <% @users.each do |user| %> <%= render "user", card: @card, user: user %> <% end %> + + <% end %> <% end %> From a24136aa5f99df546fe6e0121b1c928ddfd4a324 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 16 Dec 2025 08:43:57 +0100 Subject: [PATCH 100/274] Fix icon clipping --- app/assets/stylesheets/steps.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/steps.css index f106859ac..cb0bc6f78 100644 --- a/app/assets/stylesheets/steps.css +++ b/app/assets/stylesheets/steps.css @@ -102,8 +102,9 @@ .icon { background-color: var(--color-ink-inverted); - block-size: 0.8em; - inline-size: 0.8em; + block-size: 0.875em; + inline-size: 0.875em; + mask-size: cover; } } } \ No newline at end of file From 3e4f1451c988c6187510c6f6b0725eabede646d6 Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Tue, 16 Dec 2025 08:44:28 +0100 Subject: [PATCH 101/274] Adjust spacing between icon and text --- app/assets/stylesheets/card-columns.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index c239e1b9d..f007ee8ea 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -463,6 +463,7 @@ .card__steps { display: flex; + --column-gap: 0.8ch; } .card__tags { From 2616045476fb3bdc9072862a1e2858d93bf1703e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 11:50:27 +0100 Subject: [PATCH 102/274] Add rubocop linting --- saas/.github/workflows/lint.yml | 25 ++++++++++++++++ saas/.rubocop.yml | 5 ++++ saas/Gemfile | 2 ++ saas/Gemfile.lock | 43 ++++++++++++++++++++++++++++ saas/lib/tasks/fizzy/saas_tasks.rake | 3 +- 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 saas/.github/workflows/lint.yml diff --git a/saas/.github/workflows/lint.yml b/saas/.github/workflows/lint.yml new file mode 100644 index 000000000..bfcd0ef2d --- /dev/null +++ b/saas/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +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/.rubocop.yml b/saas/.rubocop.yml index f9d86d4a5..16773cd4c 100644 --- a/saas/.rubocop.yml +++ b/saas/.rubocop.yml @@ -6,3 +6,8 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml } # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` # Layout/SpaceInsideArrayLiteralBrackets: # Enabled: false + +AllCops: + Exclude: + - 'db/migrate/**/*' + - 'db/schema*.rb' diff --git a/saas/Gemfile b/saas/Gemfile index f978b0dbb..bb3829719 100644 --- a/saas/Gemfile +++ b/saas/Gemfile @@ -5,3 +5,5 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "queenbee", bc: "queenbee-plugin", ref: "14312a940471e20617b38cdec7c092a01567d18b" gem "rails_structured_logging", bc: "rails-structured-logging" gem "activeresource", require: "active_resource" # needed by queenbee + +gem "rubocop-rails-omakase", require: false diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock index 0dacabb04..16c647b27 100644 --- a/saas/Gemfile.lock +++ b/saas/Gemfile.lock @@ -101,6 +101,7 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + ast (2.4.3) base64 (0.3.0) bigdecimal (3.2.3) builder (3.3.0) @@ -121,6 +122,8 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.16.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) logger (1.7.0) loofah (2.24.1) crass (~> 1.0.2) @@ -160,9 +163,14 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc pp (0.6.3) prettyprint prettyprint (0.2.0) + prism (1.6.0) psych (5.2.6) date stringio @@ -205,14 +213,45 @@ GEM thor (~> 1.0, >= 1.2.2) tsort (>= 0.2) zeitwerk (~> 2.6) + rainbow (3.1.1) rake (13.3.1) rdoc (6.15.1) erb psych (>= 4.0.0) tsort + regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.2) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) securerandom (0.4.1) stringio (3.1.8) thor (1.4.0) @@ -220,6 +259,9 @@ GEM tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uri (1.0.3) useragent (0.16.11) websocket-driver (0.8.0) @@ -243,6 +285,7 @@ DEPENDENCIES activeresource queenbee! rails_structured_logging! + rubocop-rails-omakase BUNDLED WITH 2.7.0 diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index ea60d0454..6b423e3c3 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -9,10 +9,9 @@ namespace :test do # end desc "Run tests for fizzy-saas gem" - Rake::TestTask.new(:saas => :environment) 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 end end - From 984a5dd4ce158b60b3dd8733dd03fa0de99b73ad Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 10 Dec 2025 11:12:30 +0100 Subject: [PATCH 103/274] Add Stripe-based billing system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Jason Zimdars --- saas/.kamal/secrets.beta | 5 +- saas/.kamal/secrets.production | 5 +- saas/.kamal/secrets.staging | 5 +- saas/app/assets/stylesheets/fizzy/saas.css | 64 +++++++++ .../stylesheets/fizzy/saas/application.css | 15 --- .../account/billing_portals_controller.rb | 19 +++ .../account/subscriptions_controller.rb | 38 ++++++ .../admin/account_searches_controller.rb | 5 + .../controllers/admin/accounts_controller.rb | 25 ++++ .../app/controllers/admin/stats_controller.rb | 20 +++ saas/app/controllers/concerns/card/limited.rb | 12 ++ .../controllers/stripe/webhooks_controller.rb | 83 ++++++++++++ saas/app/helpers/subscriptions_helper.rb | 11 ++ saas/app/models/account/billing.rb | 29 ++++ saas/app/models/account/subscription.rb | 17 +++ saas/app/models/plan.rb | 54 ++++++++ .../account/settings/_subscription.html.erb | 7 + .../settings/_subscription_panel.html.erb | 28 ++++ .../account/subscriptions/_upgrade.html.erb | 6 + .../views/account/subscriptions/show.html.erb | 9 ++ saas/app/views/admin/accounts/edit.html.erb | 14 ++ saas/app/views/admin/accounts/index.html.erb | 6 + saas/app/views/admin/stats/show.html.erb | 126 ++++++++++++++++++ .../container/footer/saas/_create.html.erb | 5 + .../footer/saas/_near_notice.html.erb | 5 + saas/config/deploy.beta.yml | 3 + saas/config/deploy.production.yml | 3 + saas/config/deploy.staging.yml | 3 + saas/config/routes.rb | 3 + ...1203144630_create_account_subscriptions.rb | 15 +++ saas/db/saas_schema.rb | 17 ++- saas/fizzy-saas.gemspec | 2 +- saas/lib/fizzy/saas/engine.rb | 28 ++++ saas/lib/fizzy/saas/testing.rb | 11 ++ .../billing_portals_controller_test.rb | 29 ++++ .../accounts/subscriptions_controller_test.rb | 47 +++++++ .../admin/accounts_controller_test.rb | 58 ++++++++ .../admin/stats_controller_test.rb | 27 ++++ saas/test/controllers/card/limited_test.rb | 15 +++ .../stripe/webhooks_controller_test.rb | 95 +++++++++++++ saas/test/fixtures/account_subscriptions.yml | 11 ++ saas/test/models/account/billing_test.rb | 49 +++++++ saas/test/models/account/subscription_test.rb | 18 +++ saas/test/models/plan_test.rb | 11 ++ 44 files changed, 1038 insertions(+), 20 deletions(-) create mode 100644 saas/app/assets/stylesheets/fizzy/saas.css delete mode 100644 saas/app/assets/stylesheets/fizzy/saas/application.css create mode 100644 saas/app/controllers/account/billing_portals_controller.rb create mode 100644 saas/app/controllers/account/subscriptions_controller.rb create mode 100644 saas/app/controllers/admin/account_searches_controller.rb create mode 100644 saas/app/controllers/admin/accounts_controller.rb create mode 100644 saas/app/controllers/admin/stats_controller.rb create mode 100644 saas/app/controllers/concerns/card/limited.rb create mode 100644 saas/app/controllers/stripe/webhooks_controller.rb create mode 100644 saas/app/helpers/subscriptions_helper.rb create mode 100644 saas/app/models/account/billing.rb create mode 100644 saas/app/models/account/subscription.rb create mode 100644 saas/app/models/plan.rb create mode 100644 saas/app/views/account/settings/_subscription.html.erb create mode 100644 saas/app/views/account/settings/_subscription_panel.html.erb create mode 100644 saas/app/views/account/subscriptions/_upgrade.html.erb create mode 100644 saas/app/views/account/subscriptions/show.html.erb create mode 100644 saas/app/views/admin/accounts/edit.html.erb create mode 100644 saas/app/views/admin/accounts/index.html.erb create mode 100644 saas/app/views/admin/stats/show.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/_create.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/_near_notice.html.erb create mode 100644 saas/db/migrate/20251203144630_create_account_subscriptions.rb create mode 100644 saas/test/controllers/accounts/billing_portals_controller_test.rb create mode 100644 saas/test/controllers/accounts/subscriptions_controller_test.rb create mode 100644 saas/test/controllers/admin/accounts_controller_test.rb create mode 100644 saas/test/controllers/admin/stats_controller_test.rb create mode 100644 saas/test/controllers/card/limited_test.rb create mode 100644 saas/test/controllers/stripe/webhooks_controller_test.rb create mode 100644 saas/test/fixtures/account_subscriptions.yml create mode 100644 saas/test/models/account/billing_test.rb create mode 100644 saas/test/models/account/subscription_test.rb create mode 100644 saas/test/models/plan_test.rb diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 744e38da3..0fb3db660 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) +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) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ 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) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_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 d94718f6c..672359a13 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) +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) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ 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) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_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 4dad7c099..7121f5e3a 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) +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) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ 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) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_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/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css new file mode 100644 index 000000000..318c749e0 --- /dev/null +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -0,0 +1,64 @@ +/* Subscriptions +/* ------------------------------------------------------------------------ */ +:root { + --settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter))); + --settings-subscription-color: oklch(var(--lch-violet-medium)); + --settings-subscription-text-color: oklch(var(--lch-violet-dark)); + + .settings-subscription__button { + --btn-background: var(--settings-subscription-color); + --btn-border-color: var(--color-canvas); + --btn-color: var(--color-canvas); + --focus-ring-color: var(--color-ink); + } + + .settings-subscription__divider { + --divider-color: currentColor; + + color: var(--settings-subscription-color); + margin-block-start: calc(var(--block-space-half) * -1); + } + + .settings-subscription__footer { + color: var(--settings-subscription-text-color); + + &::before { + content: "————"; + display: block; + margin: auto; + text-align: center; + } + } + + .settings-subscription__notch { + animation: wiggle 500ms ease; + background: var(--settings-subscription-background); + box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color); + border-radius: 3em; + color: var(--settings-subscription-text-color); + margin-inline: auto; + padding: 0 0.3em 0 1.2em; + transform: rotate(-1deg); + + @media (max-width: 640px) { + padding-block: 0.6em; + padding-inline-start: 0.3em; + } + + .btn { + margin-block: 0.3em; + } + } + + .settings-subscription__panel { + background: var(--settings-subscription-background); + } + + .settings_subscription__warning { + color: var(--settings-subscription-text-color); + + a { + color: var(--settings-subscription-text-color); + } + } +} diff --git a/saas/app/assets/stylesheets/fizzy/saas/application.css b/saas/app/assets/stylesheets/fizzy/saas/application.css deleted file mode 100644 index 0ebd7fe82..000000000 --- a/saas/app/assets/stylesheets/fizzy/saas/application.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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/account/billing_portals_controller.rb b/saas/app/controllers/account/billing_portals_controller.rb new file mode 100644 index 000000000..67eaffe6c --- /dev/null +++ b/saas/app/controllers/account/billing_portals_controller.rb @@ -0,0 +1,19 @@ +class Account::BillingPortalsController < ApplicationController + before_action :ensure_admin + before_action :ensure_subscribed_account + + def show + redirect_to create_stripe_billing_portal_session.url, allow_other_host: true + end + + private + def ensure_subscribed_account + unless Current.account.subscribed? + redirect_to account_subscription_path, alert: "No billing information found" + end + end + + def create_stripe_billing_portal_session + Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url) + end +end diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb new file mode 100644 index 000000000..5a7f27fcd --- /dev/null +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -0,0 +1,38 @@ +class Account::SubscriptionsController < ApplicationController + before_action :ensure_admin + before_action :set_stripe_session, only: :show + + def show + end + + def create + session = Stripe::Checkout::Session.create \ + customer: find_or_create_stripe_customer, + mode: "subscription", + line_items: [ { price: Plan.paid.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 } + + redirect_to session.url, allow_other_host: true + end + + private + def set_stripe_session + @session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] + end + + def find_or_create_stripe_customer + find_stripe_customer || create_stripe_customer + end + + def find_stripe_customer + Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id + end + + 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") + end + end +end diff --git a/saas/app/controllers/admin/account_searches_controller.rb b/saas/app/controllers/admin/account_searches_controller.rb new file mode 100644 index 000000000..db655a9ef --- /dev/null +++ b/saas/app/controllers/admin/account_searches_controller.rb @@ -0,0 +1,5 @@ +class Admin::AccountSearchesController < AdminController + def create + redirect_to saas.edit_admin_account_path(params[:q]) + end +end diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb new file mode 100644 index 000000000..dda0794e4 --- /dev/null +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -0,0 +1,25 @@ +class Admin::AccountsController < AdminController + layout "public" + + before_action :set_account, only: %i[ edit update ] + + def index + end + + def edit + end + + def update + @account.update!(account_params) + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated" + end + + private + def set_account + @account = Account.find_by!(external_account_id: params[:id]) + end + + def account_params + params.expect(account: [ :cards_count ]) + end +end diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb new file mode 100644 index 000000000..6d508fc2c --- /dev/null +++ b/saas/app/controllers/admin/stats_controller.rb @@ -0,0 +1,20 @@ +class Admin::StatsController < AdminController + layout "public" + + def show + @accounts_total = Account.count + @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count + @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count + + @identities_total = Identity.count + @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count + @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count + + @top_accounts = Account + .where("cards_count > 0") + .order(cards_count: :desc) + .limit(20) + + @recent_accounts = Account.order(created_at: :desc).limit(10) + end +end diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb new file mode 100644 index 000000000..c76c4929b --- /dev/null +++ b/saas/app/controllers/concerns/card/limited.rb @@ -0,0 +1,12 @@ +module Card::Limited + extend ActiveSupport::Concern + + included do + before_action :ensure_can_create_cards, only: %i[ create ] + end + + private + def ensure_can_create_cards + head :forbidden if Current.account.exceeding_card_limit? + end +end diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb new file mode 100644 index 000000000..31839e089 --- /dev/null +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -0,0 +1,83 @@ +class Stripe::WebhooksController < ApplicationController + allow_unauthenticated_access + skip_before_action :require_account + skip_before_action :verify_authenticity_token + + def create + event = verify_webhook_signature + return head :bad_request unless event + + dispatch_stripe_event(event) + + head :ok + end + + private + def dispatch_stripe_event(event) + case event.type + when "checkout.session.completed" + handle_checkout_completed(event.data.object) + when "customer.subscription.updated" + handle_subscription_updated(event.data.object) + when "customer.subscription.deleted" + handle_subscription_deleted(event.data.object) + when "invoice.payment_failed" + handle_payment_failed(event.data.object) + end + end + + def verify_webhook_signature + payload = request.body.read + sig_header = request.env["HTTP_STRIPE_SIGNATURE"] + + Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]) + rescue Stripe::SignatureVerificationError => e + Rails.logger.error "Stripe webhook signature verification failed: #{e.message}" + nil + end + + def handle_checkout_completed(session) + return unless session.mode == "subscription" + + subscription = find_subscription_by_customer(session.customer) + return unless subscription + + stripe_subscription = Stripe::Subscription.retrieve(session.subscription) + + subscription.update! \ + stripe_subscription_id: stripe_subscription.id, + plan_key: session.metadata["plan_key"], + status: stripe_subscription.status, + current_period_end: extract_current_period_end(stripe_subscription) + end + + def handle_subscription_updated(stripe_subscription) + if subscription = find_subscription_by_customer(stripe_subscription.customer) + subscription.update! \ + status: stripe_subscription.status, + current_period_end: extract_current_period_end(stripe_subscription), + cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil + end + end + + def handle_subscription_deleted(stripe_subscription) + if subscription = find_subscription_by_customer(stripe_subscription.customer) + subscription.update!(status: "canceled", stripe_subscription_id: nil) + end + end + + def handle_payment_failed(invoice) + if subscription = find_subscription_by_customer(invoice.customer) + subscription.update!(status: "past_due") + end + end + + def find_subscription_by_customer(customer_id) + Account::Subscription.find_by(stripe_customer_id: customer_id) + end + + def extract_current_period_end(stripe_subscription) + timestamp = stripe_subscription.items.data.first&.current_period_end + Time.at(timestamp) if timestamp + end +end diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb new file mode 100644 index 000000000..32dd45402 --- /dev/null +++ b/saas/app/helpers/subscriptions_helper.rb @@ -0,0 +1,11 @@ +module SubscriptionsHelper + def subscription_period_end_action(subscription) + if subscription.to_be_canceled? + "Your Fizzy subscription ends on" + elsif subscription.canceled? + "Your Fizzy subscription ended on" + else + "Your next payment of $#{ subscription.plan.price } will be billed on".html_safe + end + end +end diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb new file mode 100644 index 000000000..69c033817 --- /dev/null +++ b/saas/app/models/account/billing.rb @@ -0,0 +1,29 @@ +module Account::Billing + extend ActiveSupport::Concern + + included do + has_one :subscription, class_name: "Account::Subscription", dependent: :destroy + end + + NEAR_CARD_LIMIT_THRESHOLD = 100 + + def plan + active_subscription&.plan || Plan.free + end + + def active_subscription + subscription if subscription&.active? + end + + def subscribed? + subscription.present? + end + + def nearing_plan_cards_limit? + plan.limit_cards? && (plan.card_limit - cards_count) < NEAR_CARD_LIMIT_THRESHOLD + end + + def exceeding_card_limit? + cards_count > plan.card_limit + end +end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb new file mode 100644 index 000000000..cc4989343 --- /dev/null +++ b/saas/app/models/account/subscription.rb @@ -0,0 +1,17 @@ +class Account::Subscription < SaasRecord + belongs_to :account + + enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself) + + validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) } + + delegate :paid?, to: :plan + + def plan + Plan.find(plan_key) + end + + def to_be_canceled? + active? && cancel_at.present? + end +end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb new file mode 100644 index 000000000..e07bf73d8 --- /dev/null +++ b/saas/app/models/plan.rb @@ -0,0 +1,54 @@ +class Plan + PLANS = { + free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, + monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID", "price_1SaHykRwChFE4it8PePOdDpS") } + } + + attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id + + class << self + def all + @all ||= PLANS.map { |key, properties| new(key: key, **properties) } + end + + def free + @free ||= all.find(&:free?) + end + + def paid + @paid ||= all.find(&:paid?) + end + + def find(key) + @all_by_key ||= all.index_by(&:key).with_indifferent_access + @all_by_key[key] + end + + alias [] find + end + + def initialize(key:, name:, price:, card_limit:, storage_limit:, stripe_price_id: nil) + @key = key + @name = name + @price = price + @card_limit = card_limit + @storage_limit = storage_limit + @stripe_price_id = stripe_price_id + end + + def free? + price.zero? + end + + def paid? + !free? + end + + def limit_cards? + card_limit != Float::INFINITY + end + + def formatted_storage_limit + ActionController::Base.helpers.number_to_human_size(storage_limit).delete(" ") + end +end diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb new file mode 100644 index 000000000..3aef9b853 --- /dev/null +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -0,0 +1,7 @@ + + +<% 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 } %> diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb new file mode 100644 index 000000000..d2b865ed8 --- /dev/null +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -0,0 +1,28 @@ +<% if Current.user.admin? %> +
+

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.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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

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

Cancel any time, no contracts, take your data with you whenever.

+ + <% else %> +

Thank you for buying Fizzy

+ + <% if Current.account.subscription %> + <%= render "account/settings/subscription", subscription: Current.account.subscription %> + + <% end %> + <% end %> + +
+<% end %> diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb new file mode 100644 index 000000000..136bec340 --- /dev/null +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -0,0 +1,6 @@ +
+ 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 Unlimitted + <% end %> +
diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb new file mode 100644 index 000000000..762bab720 --- /dev/null +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -0,0 +1,9 @@ +<% @page_title = "Thank you" %> + +<% if @session&.payment_status == "paid" %> +
+

Thanks for buying Fizzy!

+

Your payment was successful. You're now on the <%= Current.account.plan.name %> plan.

+ <%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %> +
+<% end %> diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb new file mode 100644 index 000000000..4245c8cd5 --- /dev/null +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -0,0 +1,14 @@ +

Edit Account <%= @account.external_account_id %>

+ +

Name: <%= @account.name %>

+ +<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id) do |form| %> +

+ <%= form.label :cards_count %> + <%= form.number_field :cards_count %> +

+ + <%= form.submit "Update" %> +<% end %> + +

<%= link_to "Back", saas.admin_accounts_path %>

diff --git a/saas/app/views/admin/accounts/index.html.erb b/saas/app/views/admin/accounts/index.html.erb new file mode 100644 index 000000000..e3faa98f2 --- /dev/null +++ b/saas/app/views/admin/accounts/index.html.erb @@ -0,0 +1,6 @@ +

Find Account

+ +<%= form_with url: saas.admin_account_search_path do |form| %> + <%= form.text_field :q, placeholder: "Account ID", autofocus: true %> + <%= form.submit "Search" %> +<% end %> diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb new file mode 100644 index 000000000..7c7491d2a --- /dev/null +++ b/saas/app/views/admin/stats/show.html.erb @@ -0,0 +1,126 @@ +<% @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/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb new file mode 100644 index 000000000..295c9bd19 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_create.html.erb @@ -0,0 +1,5 @@ +<% if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/upgrade" %> +<% else %> + <%= render "cards/container/footer/create", card: card %> +<% end %> 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 new file mode 100644 index 000000000..b0b9c9170 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -0,0 +1,5 @@ +<% if Current.account.nearing_plan_cards_limit? %> +
+ You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimitted", account_settings_path(anchor: "subscription") %>. +
+<% end %> diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 8744705b4..4aefe5663 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -44,6 +44,9 @@ env: - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_SECRET_KEY + - STRIPE_WEBHOOK_SECRET tags: sc_chi: {} df_iad: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index d8e4dc4b9..70556948e 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -50,6 +50,9 @@ env: - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_SECRET_KEY + - STRIPE_WEBHOOK_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 2abc18ecb..2c0ae9516 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -50,6 +50,9 @@ env: - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_SECRET_KEY + - STRIPE_WEBHOOK_SECRET tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 7c7d4f6be..23ee39507 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -3,5 +3,8 @@ Fizzy::Saas::Engine.routes.draw do namespace :admin do mount Audits1984::Engine, at: "/console" + get "stats", to: "stats#show" + resource :account_search, only: :create + resources :accounts end end diff --git a/saas/db/migrate/20251203144630_create_account_subscriptions.rb b/saas/db/migrate/20251203144630_create_account_subscriptions.rb new file mode 100644 index 000000000..7da48060c --- /dev/null +++ b/saas/db/migrate/20251203144630_create_account_subscriptions.rb @@ -0,0 +1,15 @@ +class CreateAccountSubscriptions < ActiveRecord::Migration[8.2] + def change + create_table :account_subscriptions, id: :uuid do |t| + t.references :account, null: false, type: :uuid, index: true + t.string :plan_key + t.string :stripe_customer_id, null: false, index: { unique: true } + t.string :stripe_subscription_id, index: { unique: true } + t.string :status + t.datetime :current_period_end + t.datetime :cancel_at + + t.timestamps + end + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 19d468927..190a5e8f7 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,22 @@ # # 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 +ActiveRecord::Schema[8.2].define(version: 2025_12_03_144630) do + create_table "account_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "cancel_at" + t.datetime "created_at", null: false + t.datetime "current_period_end" + t.string "plan_key" + t.string "status" + t.string "stripe_customer_id", null: false + t.string "stripe_subscription_id" + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_subscriptions_on_account_id" + t.index ["stripe_customer_id"], name: "index_account_subscriptions_on_stripe_customer_id", unique: true + t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true + end + 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 diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 64f253588..d4f79e4ce 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |spec| 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}/**/*", "LICENSE.md", "Rakefile", "README.md"] + Dir["{app,config,db,lib}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"] end spec.add_dependency "rails", ">= 8.1.0.beta1" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index ad23bef74..bf0914a76 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -9,6 +9,28 @@ module Fizzy # moved from config/initializers/queenbee.rb 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" + end + + initializer "fizzy_saas.assets" do |app| + app.config.assets.paths << root.join("app/assets/stylesheets") + end + + initializer "fizzy.saas.routes", after: :add_routing_paths do |app| + # Routes that rely on the implicit account tenant should go here instead of in +routes.rb+. + app.routes.prepend do + namespace :account do + resource :billing_portal, only: :show + resource :subscription + end + + namespace :stripe do + resource :webhooks, only: :create + end + end + end + initializer "fizzy.saas.mount" do |app| app.routes.append do mount Fizzy::Saas::Engine => "/", as: "saas" @@ -47,6 +69,10 @@ module Fizzy end end + initializer "fizzy_saas.stripe" do + Stripe.api_key = ENV["STRIPE_SECRET_KEY"] + end + initializer "fizzy_saas.sentry" do if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank? Sentry.init do |config| @@ -101,6 +127,8 @@ module Fizzy end config.to_prepare do + ::Account.include(Account::Billing) + ::CardsController.include(Card::Limited) ::Signup.prepend(Fizzy::Saas::Signup) Queenbee::Subscription.short_names = Subscription::SHORT_NAMES diff --git a/saas/lib/fizzy/saas/testing.rb b/saas/lib/fizzy/saas/testing.rb index eeaf24954..3b40f027a 100644 --- a/saas/lib/fizzy/saas/testing.rb +++ b/saas/lib/fizzy/saas/testing.rb @@ -7,3 +7,14 @@ Queenbee::Remote::Account.class_eval do super + Random.rand(1000000) end end + +# Add engine fixtures to the test fixture paths +module Fizzy::Saas::EngineFixtures + def included(base) + super + engine_fixtures = Fizzy::Saas::Engine.root.join("test", "fixtures").to_s + base.fixture_paths << engine_fixtures unless base.fixture_paths.include?(engine_fixtures) + end +end + +ActiveRecord::TestFixtures.singleton_class.prepend(Fizzy::Saas::EngineFixtures) diff --git a/saas/test/controllers/accounts/billing_portals_controller_test.rb b/saas/test/controllers/accounts/billing_portals_controller_test.rb new file mode 100644 index 000000000..00f16e4df --- /dev/null +++ b/saas/test/controllers/accounts/billing_portals_controller_test.rb @@ -0,0 +1,29 @@ +require "test_helper" +require "ostruct" + +class Account::BillingPortalsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "redirects to stripe billing portal" do + Current.account.subscription.update!(stripe_customer_id: "cus_test123") + + session = OpenStruct.new(url: "https://billing.stripe.com/session123") + Stripe::BillingPortal::Session.expects(:create) + .with(customer: "cus_test123", return_url: account_settings_url) + .returns(session) + + get account_billing_portal_path + + assert_redirected_to "https://billing.stripe.com/session123" + end + + test "requires admin" do + logout_and_sign_in_as :david + + get account_billing_portal_path + + assert_response :forbidden + end +end diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb new file mode 100644 index 000000000..9cf5d86bc --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -0,0 +1,47 @@ +require "test_helper" +require "ostruct" + +class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "show" do + get account_subscription_path + assert_response :success + end + + test "show with session_id retrieves stripe session" do + Stripe::Checkout::Session.stubs(:retrieve).with("sess_123").returns(OpenStruct.new(id: "sess_123")) + + get account_subscription_path(session_id: "sess_123") + assert_response :success + end + + test "show requires admin" do + logout_and_sign_in_as :david + + get account_subscription_path + assert_response :forbidden + end + + test "create 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).returns(session) + + post account_subscription_path + + assert_redirected_to "https://checkout.stripe.com/session123" + end + + test "create requires admin" do + logout_and_sign_in_as :david + + post account_subscription_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 new file mode 100644 index 000000000..a6c815bcf --- /dev/null +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest + def saas + Fizzy::Saas::Engine.routes.url_helpers + end + + test "staff can access index" do + sign_in_as :david + + untenanted do + get saas.admin_accounts_path + end + + assert_response :success + end + + test "search account" do + sign_in_as :david + + untenanted do + post saas.admin_account_search_path, params: { q: accounts(:"37s").external_account_id } + assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) + end + end + + test "staff can edit account" do + sign_in_as :david + + untenanted do + get saas.edit_admin_account_path(accounts(:"37s").external_account_id) + end + + assert_response :success + end + + test "staff can update cards_count" do + sign_in_as :david + + untenanted do + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 500 } } + assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) + end + + assert_equal 500, accounts(:"37s").reload.cards_count + end + + test "non-staff cannot access accounts" do + sign_in_as :jz + + untenanted do + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 9999 } } + end + + assert_response :forbidden + assert_not_equal 9999, accounts(:"37s").reload.cards_count + end +end diff --git a/saas/test/controllers/admin/stats_controller_test.rb b/saas/test/controllers/admin/stats_controller_test.rb new file mode 100644 index 000000000..9fa0c8b3f --- /dev/null +++ b/saas/test/controllers/admin/stats_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Admin::StatsControllerTest < ActionDispatch::IntegrationTest + def saas + Fizzy::Saas::Engine.routes.url_helpers + end + + test "staff can access stats" do + sign_in_as :david + + untenanted do + get saas.admin_stats_path + end + + assert_response :success + end + + test "non-staff cannot access stats" do + sign_in_as :jz + + untenanted do + get saas.admin_stats_path + end + + assert_response :forbidden + end +end diff --git a/saas/test/controllers/card/limited_test.rb b/saas/test/controllers/card/limited_test.rb new file mode 100644 index 000000000..915cd1049 --- /dev/null +++ b/saas/test/controllers/card/limited_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class Card::LimitedTest < ActionDispatch::IntegrationTest + test "cannot create cards when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) + end + + assert_response :forbidden + end +end diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb new file mode 100644 index 000000000..7bc4897dc --- /dev/null +++ b/saas/test/controllers/stripe/webhooks_controller_test.rb @@ -0,0 +1,95 @@ +require "test_helper" +require "ostruct" + +class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest + setup do + @account = Account.create!(name: "Test") + @subscription = @account.create_subscription! \ + plan_key: "monthly_v1", + status: "incomplete", + stripe_customer_id: "cus_test123" + end + + test "invalid signature returns bad request" do + Stripe::Webhook.stubs(:construct_event).raises(Stripe::SignatureVerificationError.new("invalid", "sig")) + + post stripe_webhooks_path + assert_response :bad_request + end + + test "checkout session completed activates subscription" do + stripe_sub = OpenStruct.new(id: "sub_123", status: "active", items: stub_items(1.month.from_now.to_i)) + + event = stripe_event("checkout.session.completed", + mode: "subscription", + customer: "cus_test123", + subscription: "sub_123", + metadata: { "plan_key" => "monthly_v1" } + ) + + Stripe::Webhook.stubs(:construct_event).returns(event) + Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) + + post stripe_webhooks_path + + assert_response :ok + @subscription.reload + assert_equal "sub_123", @subscription.stripe_subscription_id + assert_equal "active", @subscription.status + end + + test "subscription updated changes status" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + event = stripe_event("customer.subscription.updated", + customer: "cus_test123", + status: "past_due", + cancel_at: nil, + items: stub_items(1.month.from_now.to_i) + ) + + Stripe::Webhook.stubs(:construct_event).returns(event) + + post stripe_webhooks_path + + assert_response :ok + assert_equal "past_due", @subscription.reload.status + end + + test "subscription deleted cancels subscription" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + event = stripe_event("customer.subscription.deleted", customer: "cus_test123") + + Stripe::Webhook.stubs(:construct_event).returns(event) + + post stripe_webhooks_path + + assert_response :ok + @subscription.reload + assert_equal "canceled", @subscription.status + assert_nil @subscription.stripe_subscription_id + end + + test "payment failed marks subscription as past due" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + event = stripe_event("invoice.payment_failed", customer: "cus_test123") + + Stripe::Webhook.stubs(:construct_event).returns(event) + + post stripe_webhooks_path + + assert_response :ok + assert_equal "past_due", @subscription.reload.status + end + + private + def stripe_event(type, **attributes) + OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes))) + end + + def stub_items(current_period_end) + OpenStruct.new(data: [ OpenStruct.new(current_period_end: current_period_end) ]) + end +end diff --git a/saas/test/fixtures/account_subscriptions.yml b/saas/test/fixtures/account_subscriptions.yml new file mode 100644 index 000000000..a18ff961b --- /dev/null +++ b/saas/test/fixtures/account_subscriptions.yml @@ -0,0 +1,11 @@ +_fixture: + model_class: Account::Subscription + +signals_monthly: + id: <%= ActiveRecord::FixtureSet.identify("signals_monthly", :uuid) %> + account_id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %> + plan_key: monthly_v1 + status: active + stripe_customer_id: cus_test_37signals + created_at: <%= Time.current %> + updated_at: <%= Time.current %> diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb new file mode 100644 index 000000000..086f60807 --- /dev/null +++ b/saas/test/models/account/billing_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class Account::BillingTest < ActiveSupport::TestCase + test "active subscription" do + account = Account.create!(name: "Test") + + # No subscription + assert_nil account.active_subscription + + # Subscription but it is not active + account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test") + assert_nil account.active_subscription + + # Active subscription exists + account.subscription.update!(status: "active") + assert_equal account.subscription, account.active_subscription + end + + test "detect nearing card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").nearing_plan_cards_limit? + + # Free plan not near limit + accounts(:initech).update_column(:cards_count, 899) + assert_not accounts(:initech).nearing_plan_cards_limit? + + # Free plan near limit + accounts(:initech).update_column(:cards_count, 900) + assert_not accounts(:initech).nearing_plan_cards_limit? + + accounts(:initech).update_column(:cards_count, 901) + assert accounts(:initech).nearing_plan_cards_limit? + end + + test "detect exceeding card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").exceeding_card_limit? + + # Free plan under limit + accounts(:initech).update_column(:cards_count, 999) + assert_not accounts(:initech).exceeding_card_limit? + + # Free plan over limit + accounts(:initech).update_column(:cards_count, 1001) + assert accounts(:initech).exceeding_card_limit? + end +end diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb new file mode 100644 index 000000000..99612ecea --- /dev/null +++ b/saas/test/models/account/subscription_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class Account::SubscriptionTest < ActiveSupport::TestCase + test "get the account plan" do + subscription = Account::Subscription.new(plan_key: "free_v1") + assert_equal Plan[:free_v1], subscription.plan + end + + test "check if account is active" do + subscription = Account::Subscription.new(status: "active") + assert subscription.active? + end + + test "check if account is paid" do + assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid? + assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid? + end +end diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb new file mode 100644 index 000000000..6e0d70b5c --- /dev/null +++ b/saas/test/models/plan_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class PlanTest < ActiveSupport::TestCase + test "free plan is free" do + assert Plan[:free_v1].free? + end + + test "monthly plan is not free" do + assert_not Plan[:monthly_v1].free? + end +end From 5be4dca80e1dc4283e61f1ff8e80cf22629be63c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 09:25:41 +0100 Subject: [PATCH 104/274] Refresh the subscription instead of relying on event payloads Events can land out of order. See https://github.com/basecamp/fizzy-saas/pull/23#discussion_r2609909558 --- .../controllers/stripe/webhooks_controller.rb | 56 +++++++------------ .../stripe/webhooks_controller_test.rb | 32 +++++------ 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 31839e089..14c50c74d 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -15,14 +15,10 @@ class Stripe::WebhooksController < ApplicationController private def dispatch_stripe_event(event) case event.type - when "checkout.session.completed" - handle_checkout_completed(event.data.object) - when "customer.subscription.updated" - handle_subscription_updated(event.data.object) - when "customer.subscription.deleted" - handle_subscription_deleted(event.data.object) - when "invoice.payment_failed" - handle_payment_failed(event.data.object) + when "checkout.session.completed" + sync_new_subscription(event.data.object.subscription, plan_key: event.data.object.metadata["plan_key"]) if event.data.object.mode == "subscription" + when "customer.subscription.updated", "customer.subscription.deleted" + sync_subscription(event.data.object.id) end end @@ -36,39 +32,29 @@ class Stripe::WebhooksController < ApplicationController nil end - def handle_checkout_completed(session) - return unless session.mode == "subscription" - - subscription = find_subscription_by_customer(session.customer) - return unless subscription - - stripe_subscription = Stripe::Subscription.retrieve(session.subscription) - - subscription.update! \ - stripe_subscription_id: stripe_subscription.id, - plan_key: session.metadata["plan_key"], - status: stripe_subscription.status, - current_period_end: extract_current_period_end(stripe_subscription) + def sync_new_subscription(stripe_subscription_id, plan_key:) + sync_subscription(stripe_subscription_id) do |subscription_properties| + subscription_properties[:plan_key] = plan_key if plan_key + end end - def handle_subscription_updated(stripe_subscription) + # Always fetch fresh subscription data from Stripe to handle out-of-order + # event delivery. Not relying on payload data. + def sync_subscription(stripe_subscription_id) + stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id) + if subscription = find_subscription_by_customer(stripe_subscription.customer) - subscription.update! \ + subscription_properties = { + stripe_subscription_id: stripe_subscription.id, status: stripe_subscription.status, - current_period_end: extract_current_period_end(stripe_subscription), + current_period_end: current_period_end_for(stripe_subscription), cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil - end - end + } - def handle_subscription_deleted(stripe_subscription) - if subscription = find_subscription_by_customer(stripe_subscription.customer) - subscription.update!(status: "canceled", stripe_subscription_id: nil) - end - end + yield subscription_properties if block_given? + subscription_properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled" - def handle_payment_failed(invoice) - if subscription = find_subscription_by_customer(invoice.customer) - subscription.update!(status: "past_due") + subscription.update!(subscription_properties) end end @@ -76,7 +62,7 @@ class Stripe::WebhooksController < ApplicationController Account::Subscription.find_by(stripe_customer_id: customer_id) end - def extract_current_period_end(stripe_subscription) + def current_period_end_for(stripe_subscription) timestamp = stripe_subscription.items.data.first&.current_period_end Time.at(timestamp) if timestamp end diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb index 7bc4897dc..c1c8ac4d0 100644 --- a/saas/test/controllers/stripe/webhooks_controller_test.rb +++ b/saas/test/controllers/stripe/webhooks_controller_test.rb @@ -18,7 +18,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest end test "checkout session completed activates subscription" do - stripe_sub = OpenStruct.new(id: "sub_123", status: "active", items: stub_items(1.month.from_now.to_i)) + stripe_sub = OpenStruct.new(id: "sub_123", customer: "cus_test123", status: "active", cancel_at: nil, items: stub_items(1.month.from_now.to_i)) event = stripe_event("checkout.session.completed", mode: "subscription", @@ -41,14 +41,18 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest test "subscription updated changes status" do @subscription.update!(stripe_subscription_id: "sub_123", status: "active") - event = stripe_event("customer.subscription.updated", + stripe_sub = OpenStruct.new( + id: "sub_123", customer: "cus_test123", status: "past_due", cancel_at: nil, items: stub_items(1.month.from_now.to_i) ) + event = stripe_event("customer.subscription.updated", id: "sub_123") + Stripe::Webhook.stubs(:construct_event).returns(event) + Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) post stripe_webhooks_path @@ -59,9 +63,18 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest test "subscription deleted cancels subscription" do @subscription.update!(stripe_subscription_id: "sub_123", status: "active") - event = stripe_event("customer.subscription.deleted", customer: "cus_test123") + stripe_sub = OpenStruct.new( + id: "sub_123", + customer: "cus_test123", + status: "canceled", + cancel_at: nil, + items: stub_items(1.month.from_now.to_i) + ) + + event = stripe_event("customer.subscription.deleted", id: "sub_123") Stripe::Webhook.stubs(:construct_event).returns(event) + Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) post stripe_webhooks_path @@ -71,19 +84,6 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest assert_nil @subscription.stripe_subscription_id end - test "payment failed marks subscription as past due" do - @subscription.update!(stripe_subscription_id: "sub_123", status: "active") - - event = stripe_event("invoice.payment_failed", customer: "cus_test123") - - Stripe::Webhook.stubs(:construct_event).returns(event) - - post stripe_webhooks_path - - assert_response :ok - assert_equal "past_due", @subscription.reload.status - end - private def stripe_event(type, **attributes) OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes))) From a25bd9adfde2ba298f59b19d6953b766fa8dfd45 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 10:34:57 +0100 Subject: [PATCH 105/274] Let's use a single sandbox environment via 1password ENV vars --- 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 e07bf73d8..24e5cc633 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID", "price_1SaHykRwChFE4it8PePOdDpS") } + monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID") } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id From 90eb61bb5f39345c15c058509f98bbf3eb8f07c7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 10:54:09 +0100 Subject: [PATCH 106/274] Add script to set stripe env vars from 1password --- saas/exe/stripe-dev | 23 +++++++++++++++++++++++ saas/fizzy-saas.gemspec | 5 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100755 saas/exe/stripe-dev diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev new file mode 100755 index 000000000..820264766 --- /dev/null +++ b/saas/exe/stripe-dev @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# +# Fetches Stripe development environment variables from 1Password. +# Usage: eval "$(bundle exec stripe-dev)" + +require "json" + +secrets_escaped = `kamal secrets fetch \ + --adapter 1password \ + --account basecamp \ + --from "Deploy/Fizzy" \ + "Development/STRIPE_SECRET_KEY" \ + "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` + +# kamal outputs shell-escaped JSON, unescape it +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"] + +puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}") +puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}") diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index d4f79e4ce..1368ef63c 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -18,9 +18,12 @@ Gem::Specification.new do |spec| 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/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"] + Dir["{app,config,db,lib,exe}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"] end + spec.bindir = "exe" + spec.executables = [ "stripe-dev" ] + spec.add_dependency "rails", ">= 8.1.0.beta1" spec.add_dependency "queenbee" spec.add_dependency "rails_structured_logging" From 5baf9d879d2a7997353cbfeda5e433e3de08a1b8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 11:12:25 +0100 Subject: [PATCH 107/274] Dont't fail if not set --- 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 24e5cc633..0d8506da7 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID") } + monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id From fc0dc9423dae1712f655a6a8633994f34e2c378c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 11:12:50 +0100 Subject: [PATCH 108/274] Kill previous tunnel automatically --- saas/exe/stripe-dev | 61 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index 820264766..c2aea61a4 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -1,10 +1,30 @@ #!/usr/bin/env ruby # -# Fetches Stripe development environment variables from 1Password. +# Fetches Stripe development environment variables from 1Password and starts +# the Stripe CLI webhook listener. +# # Usage: eval "$(bundle exec stripe-dev)" require "json" +require "fileutils" +LOG_FILE = "log/stripe.development.log" +PID_FILE = "tmp/stripe.tunnel.pid" + +# Ensure directories exist +FileUtils.mkdir_p("log") +FileUtils.mkdir_p("tmp") + +# Kill any existing stripe tunnel process +if File.exist?(PID_FILE) + old_pid = File.read(PID_FILE).strip.to_i + if old_pid > 0 + Process.kill("TERM", old_pid) rescue nil + end + File.delete(PID_FILE) +end + +# Fetch secrets from 1Password secrets_escaped = `kamal secrets fetch \ --adapter 1password \ --account basecamp \ @@ -12,12 +32,49 @@ secrets_escaped = `kamal secrets fetch \ "Development/STRIPE_SECRET_KEY" \ "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` -# kamal outputs shell-escaped JSON, unescape it 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"] +# Clear previous log file +File.write(LOG_FILE, "") + +# Start stripe listen in background +pid = spawn( + "stripe", "listen", "--forward-to", "localhost:3006/stripe/webhooks", + out: [LOG_FILE, "a"], + err: [LOG_FILE, "a"] +) +Process.detach(pid) + +# Save PID for cleanup +File.write(PID_FILE, pid.to_s) + +# Wait for the webhook secret to appear in logs +webhook_secret = nil +20.times do + sleep 0.5 + if File.exist?(LOG_FILE) + content = File.read(LOG_FILE) + if match = content.match(/webhook signing secret is (whsec_\w+)/) + webhook_secret = match[1] + break + end + end +end + +if webhook_secret.nil? + warn "Warning: Could not capture webhook secret from stripe listen" +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_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret + +# Informational message to stderr (won't be eval'd) +warn "" +warn "Stripe CLI listening (PID: #{pid})" +warn "Logs: #{LOG_FILE}" From c34e29454eb548007b8f9ba2997195fc456db018 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 14:21:07 +0100 Subject: [PATCH 109/274] Document how to work with Stripe locally --- saas/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/saas/README.md b/saas/README.md index 90bf7096d..9603c317d 100644 --- a/saas/README.md +++ b/saas/README.md @@ -24,6 +24,22 @@ After making changes to this gem, you need to update Fizzy to pick up the change BUNDLE_GEMFILE=Gemfile.saas bundle update --conservative fizzy-saas ``` +## Working with Stripe + +The first time, you need to: + +1. Install Stripe CLI: https://stripe.com/docs/stripe-cli +2. Run `stripe login` and authorize the environment `37signals Development` + +Then, for working on the Stripe integration locally, you need to run this script to start the tunneling and set the environment variables: + +```sh +eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec stripe-dev)" +bin/dev # You need to start the dev server in the same terminal session +``` + +This will ask for your 1password authorization to read and set the environment variables that Stripe needs. + ## 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 623d80017fbf93ba213511e4b1e027644e08e05a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 15:00:15 +0100 Subject: [PATCH 110/274] Add stripe envs --- saas/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/saas/README.md b/saas/README.md index 9603c317d..80ff67a36 100644 --- a/saas/README.md +++ b/saas/README.md @@ -40,6 +40,12 @@ bin/dev # You need to start the dev server in the same terminal session This will ask for your 1password authorization to read and set the environment variables that Stripe needs. +### Stripe environments + +* [Development](https://dashboard.stripe.com/acct_1SdTFtRus34tgjsJ/test/dashboard) +* [Staging](https://dashboard.stripe.com/acct_1SdTbuRvb8txnPBR/test/dashboard) +* [Production](https://dashboard.stripe.com/acct_1SNy97RwChFE4it8/dashboard) + ## 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 ca5095cafdbf9a67b4b1dc050cbc232a15662774 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 15:56:55 +0100 Subject: [PATCH 111/274] Fix typo with unlimitted --- saas/app/views/account/settings/_subscription_panel.html.erb | 4 ++-- .../views/cards/container/footer/saas/_near_notice.html.erb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index d2b865ed8..a29b85194 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -14,13 +14,13 @@ <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel any time, no contracts, take your data with you whenever.

- + <% else %>

Thank you for buying Fizzy

<% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - + <% end %> <% end %> 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 b0b9c9170..9afdcb131 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,5 +1,5 @@ <% if Current.account.nearing_plan_cards_limit? %>
- You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimitted", account_settings_path(anchor: "subscription") %>. + You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>.
<% end %> From d5018cab2388e5bec230d67ec4cc02fdbd51dfc8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 16:10:45 +0100 Subject: [PATCH 112/274] Fix typo --- saas/app/views/account/settings/_subscription_panel.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index a29b85194..4837974ad 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -13,7 +13,7 @@ <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> -

Cancel any time, no contracts, take your data with you whenever.

+

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

<% else %>

Thank you for buying Fizzy

From e53455d2cabd6a3e047b3bb6275f6ece6006ac6b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 17:00:40 +0100 Subject: [PATCH 113/274] Paid plans never exceed limits! --- saas/app/models/account/billing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 69c033817..be938701d 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -24,6 +24,6 @@ module Account::Billing end def exceeding_card_limit? - cards_count > plan.card_limit + plan.limit_cards? && cards_count > plan.card_limit end end From d556f051a694d2aeb7d0b87fa141016e319a1bc3 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:16:13 -0600 Subject: [PATCH 114/274] Spelling --- saas/app/models/plan.rb | 2 +- saas/app/views/account/subscriptions/_upgrade.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 0d8506da7..77a2cfbc7 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimitted", 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"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index 136bec340..c7aa27863 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,6 +1,6 @@
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 Unlimitted + Upgrade to Unlimited <% end %>
From 99535085410281c48edf2568950144bda1111703 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:27:30 -0600 Subject: [PATCH 115/274] Fancy apostrophes --- .../app/views/account/settings/_subscription_panel.html.erb | 6 +++--- saas/app/views/account/subscriptions/_upgrade.html.erb | 2 +- saas/app/views/account/subscriptions/show.html.erb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 4837974ad..48bcc100a 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -9,18 +9,18 @@

You've used <%= Current.account.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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

+

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

<%= button_to "Upgrade to #{Plan.paid.name} fo $#{ 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.

- + <% else %>

Thank you for buying Fizzy

<% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - + <% end %> <% end %> diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index c7aa27863..3a8fa126b 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,5 +1,5 @@
- 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/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb index 762bab720..7f3c18ffb 100644 --- a/saas/app/views/account/subscriptions/show.html.erb +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -3,7 +3,7 @@ <% if @session&.payment_status == "paid" %>

Thanks for buying Fizzy!

-

Your payment was successful. You're now on the <%= Current.account.plan.name %> plan.

+

Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.

<%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %>
<% end %> From 5957ed64494bdd95fc20fb6b313349501a0920a9 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:29:00 -0600 Subject: [PATCH 116/274] Typo --- saas/app/views/account/settings/_subscription_panel.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 48bcc100a..ae2a86ac5 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -11,7 +11,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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

- <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ 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 $#{ 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.

From b1a8b3c859f147d5ca1d199c9715fb46ab9cea23 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:32:47 -0600 Subject: [PATCH 117/274] Layout --- saas/app/views/account/subscriptions/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb index 7f3c18ffb..64987b5d0 100644 --- a/saas/app/views/account/subscriptions/show.html.erb +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -1,7 +1,7 @@ <% @page_title = "Thank you" %> <% if @session&.payment_status == "paid" %> -
+

Thanks for buying Fizzy!

Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.

<%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %> From 6411268d7a56fa7a60e20342c1d1e5e66748d79c Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:40:37 -0600 Subject: [PATCH 118/274] More fancy apostrophes --- .../app/views/account/settings/_subscription_panel.html.erb | 6 +++--- .../views/cards/container/footer/saas/_near_notice.html.erb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index ae2a86ac5..58fb60891 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -4,12 +4,12 @@ <% if Current.account.plan.free? %> <% if Current.account.exceeding_card_limit? %> -

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

+

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

<% else %> -

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

+

You’ve used <%= Current.account.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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

+

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 <%= number_to_human_size(Plan.paid.formatted_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 } } %> 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 9afdcb131..5b7ee42b3 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,5 +1,5 @@ <% if Current.account.nearing_plan_cards_limit? %>
- You've used <%= Current.account.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.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>.
<% end %> From b19770c2a81bb024e8ad87ea267a558d1f23d2cd Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 15:50:24 -0600 Subject: [PATCH 119/274] Show different things to non-admins --- saas/app/views/account/subscriptions/_upgrade.html.erb | 10 +++++++--- .../cards/container/footer/saas/_near_notice.html.erb | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index 3a8fa126b..a23abce0a 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,6 +1,10 @@
- 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 + <% 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 %> + <% else %> + This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. <% end %>
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 5b7ee42b3..7b34f7404 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,5 +1,9 @@ <% if Current.account.nearing_plan_cards_limit? %>
- You’ve used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. + <% if Current.user.admin? %> + You’ve used <%= Current.account.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.cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon + <% end %>
<% end %> From 7a5e29699c7f65295b5abb4a3b8e24893b8d2723 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 16:18:26 -0600 Subject: [PATCH 120/274] Give this some padding --- saas/app/views/account/subscriptions/_upgrade.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index a23abce0a..d1c06e7f0 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -5,6 +5,8 @@ Upgrade to Unlimited <% end %> <% else %> - This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
+ This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
<% end %>
From c8330a7be86c568939ffd8a95f4a50cf4e7c3f0c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 11:07:14 +0100 Subject: [PATCH 121/274] Don't prevent creating drafts, or you won't get to see the upgrade banner Instead: - Always let you create drafts when pressing "Add card" - Prevent creations of published cards via API - Prevent publication of cards in all cases when the limit is exceeded --- .../concerns/card/limited_creation.rb | 14 ++++++ .../{limited.rb => limited_publishing.rb} | 6 +-- saas/lib/fizzy/saas/engine.rb | 3 +- .../controllers/card/limited_creation_test.rb | 43 +++++++++++++++++++ .../card/limited_publishing_test.rb | 14 ++++++ saas/test/controllers/card/limited_test.rb | 15 ------- 6 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 saas/app/controllers/concerns/card/limited_creation.rb rename saas/app/controllers/concerns/card/{limited.rb => limited_publishing.rb} (52%) create mode 100644 saas/test/controllers/card/limited_creation_test.rb create mode 100644 saas/test/controllers/card/limited_publishing_test.rb delete mode 100644 saas/test/controllers/card/limited_test.rb diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb new file mode 100644 index 000000000..b4c773449 --- /dev/null +++ b/saas/app/controllers/concerns/card/limited_creation.rb @@ -0,0 +1,14 @@ +module Card::LimitedCreation + extend ActiveSupport::Concern + + included do + # 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? } + 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.rb b/saas/app/controllers/concerns/card/limited_publishing.rb similarity index 52% rename from saas/app/controllers/concerns/card/limited.rb rename to saas/app/controllers/concerns/card/limited_publishing.rb index c76c4929b..b92ab06f3 100644 --- a/saas/app/controllers/concerns/card/limited.rb +++ b/saas/app/controllers/concerns/card/limited_publishing.rb @@ -1,12 +1,12 @@ -module Card::Limited +module Card::LimitedPublishing extend ActiveSupport::Concern included do - before_action :ensure_can_create_cards, only: %i[ create ] + before_action :ensure_can_publish_cards, only: %i[ create ] end private - def ensure_can_create_cards + def ensure_can_publish_cards head :forbidden if Current.account.exceeding_card_limit? end end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index bf0914a76..512e4884e 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -128,8 +128,9 @@ module Fizzy config.to_prepare do ::Account.include(Account::Billing) - ::CardsController.include(Card::Limited) ::Signup.prepend(Fizzy::Saas::Signup) + CardsController.include(Card::LimitedCreation) + Cards::PublishesController.include(Card::LimitedPublishing) Queenbee::Subscription.short_names = Subscription::SHORT_NAMES diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb new file mode 100644 index 000000000..b3477735c --- /dev/null +++ b/saas/test/controllers/card/limited_creation_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Card::LimitedCreationTest < ActionDispatch::IntegrationTest + test "cannot create cards via JSON when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + 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 + + test "can create cards via HTML when card limit exceeded but they are drafts" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all + + assert_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) + end + + assert_response :redirect + assert Card.last.drafted? + end + + test "cannot force published status via HTML when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all + + assert_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { status: "published" } } + end + + assert_response :redirect + assert Card.last.drafted? + end +end diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb new file mode 100644 index 000000000..2eb57471e --- /dev/null +++ b/saas/test/controllers/card/limited_publishing_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest + test "cannot publish cards when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :forbidden + assert cards(:unfinished_thoughts).reload.drafted? + end +end diff --git a/saas/test/controllers/card/limited_test.rb b/saas/test/controllers/card/limited_test.rb deleted file mode 100644 index 915cd1049..000000000 --- a/saas/test/controllers/card/limited_test.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "test_helper" - -class Card::LimitedTest < ActionDispatch::IntegrationTest - test "cannot create cards when card limit exceeded" do - sign_in_as :mike - - accounts(:initech).update_column(:cards_count, 1001) - - assert_no_difference -> { Card.count } do - post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) - end - - assert_response :forbidden - end -end From f4184e5a612ba97d343b7fbe7700abb57161e7df Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 11:15:30 +0100 Subject: [PATCH 122/274] Remove redundant condition --- saas/app/models/account/billing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index be938701d..69c033817 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -24,6 +24,6 @@ module Account::Billing end def exceeding_card_limit? - plan.limit_cards? && cards_count > plan.card_limit + cards_count > plan.card_limit end end From 725e850802cf384b101915876bd3ca7fd5c33071 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 11:55:13 +0100 Subject: [PATCH 123/274] Enable automatic taxes and calculate based on mandatory billing address https://app.fizzy.do/5986089/cards/3465 --- saas/app/controllers/account/subscriptions_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 5a7f27fcd..1e4c6d87e 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -12,7 +12,10 @@ class Account::SubscriptionsController < ApplicationController line_items: [ { price: Plan.paid.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.paid.key }, + automatic_tax: { enabled: true }, + billing_address_collection: "required", + customer_update: { address: "auto" } redirect_to session.url, allow_other_host: true end From 3ef5e4eeef3552916a0d7bfbeefee60fd9668fda Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 14:49:47 +0100 Subject: [PATCH 124/274] Add record to track overridden limits Before, we were relying on just changing the cards_count in account, but this could create problems where the system to calculate the next card number fails due to the unique constraint. --- .../controllers/admin/accounts_controller.rb | 10 ++-- .../admin/overridden_limits_controller.rb | 8 +++ .../concerns/admin/account_scoped.rb | 12 +++++ saas/app/models/account/billing.rb | 10 ---- saas/app/models/account/limited.rb | 34 +++++++++++++ saas/app/models/account/overridden_limits.rb | 4 ++ .../settings/_subscription_panel.html.erb | 2 +- saas/app/views/admin/accounts/edit.html.erb | 39 ++++++++++---- .../footer/saas/_near_notice.html.erb | 4 +- saas/config/routes.rb | 4 +- ...140000_create_account_overridden_limits.rb | 10 ++++ saas/db/saas_schema.rb | 10 +++- saas/lib/fizzy/saas/engine.rb | 4 +- saas/lib/tasks/fizzy/saas_tasks.rake | 7 --- .../admin/accounts_controller_test.rb | 10 ++-- .../overridden_limits_controller_test.rb | 22 ++++++++ .../admin/stats_controller_test.rb | 4 -- saas/test/models/account/billing_test.rb | 31 ----------- saas/test/models/account/limited_test.rb | 51 +++++++++++++++++++ 19 files changed, 196 insertions(+), 80 deletions(-) create mode 100644 saas/app/controllers/admin/overridden_limits_controller.rb create mode 100644 saas/app/controllers/concerns/admin/account_scoped.rb create mode 100644 saas/app/models/account/limited.rb create mode 100644 saas/app/models/account/overridden_limits.rb create mode 100644 saas/db/migrate/20251215140000_create_account_overridden_limits.rb create mode 100644 saas/test/controllers/admin/overridden_limits_controller_test.rb create mode 100644 saas/test/models/account/limited_test.rb diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb index dda0794e4..ba6200d1b 100644 --- a/saas/app/controllers/admin/accounts_controller.rb +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -1,4 +1,6 @@ class Admin::AccountsController < AdminController + include Admin::AccountScoped + layout "public" before_action :set_account, only: %i[ edit update ] @@ -10,8 +12,8 @@ class Admin::AccountsController < AdminController end def update - @account.update!(account_params) - redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated" + @account.override_limits(card_count: overridden_card_count_param) + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated" end private @@ -19,7 +21,7 @@ class Admin::AccountsController < AdminController @account = Account.find_by!(external_account_id: params[:id]) end - def account_params - params.expect(account: [ :cards_count ]) + def overridden_card_count_param + params[:account][:overridden_card_count].to_i end end diff --git a/saas/app/controllers/admin/overridden_limits_controller.rb b/saas/app/controllers/admin/overridden_limits_controller.rb new file mode 100644 index 000000000..fff8ea3e3 --- /dev/null +++ b/saas/app/controllers/admin/overridden_limits_controller.rb @@ -0,0 +1,8 @@ +class Admin::OverriddenLimitsController < AdminController + include Admin::AccountScoped + + def destroy + @account.reset_overridden_limits + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Limits reset" + end +end diff --git a/saas/app/controllers/concerns/admin/account_scoped.rb b/saas/app/controllers/concerns/admin/account_scoped.rb new file mode 100644 index 000000000..314720cd2 --- /dev/null +++ b/saas/app/controllers/concerns/admin/account_scoped.rb @@ -0,0 +1,12 @@ +module Admin::AccountScoped + extend ActiveSupport::Concern + + included do + before_action :set_account + end + + private + def set_account + @account = Account.find_by!(external_account_id: params[:account_id] || params[:id]) + end +end diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 69c033817..2ef42edb7 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -5,8 +5,6 @@ module Account::Billing has_one :subscription, class_name: "Account::Subscription", dependent: :destroy end - NEAR_CARD_LIMIT_THRESHOLD = 100 - def plan active_subscription&.plan || Plan.free end @@ -18,12 +16,4 @@ module Account::Billing def subscribed? subscription.present? end - - def nearing_plan_cards_limit? - plan.limit_cards? && (plan.card_limit - cards_count) < NEAR_CARD_LIMIT_THRESHOLD - end - - def exceeding_card_limit? - cards_count > plan.card_limit - end end diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb new file mode 100644 index 000000000..6fed72e9e --- /dev/null +++ b/saas/app/models/account/limited.rb @@ -0,0 +1,34 @@ +module Account::Limited + extend ActiveSupport::Concern + + included do + has_one :overridden_limits, class_name: "Account::OverriddenLimits", dependent: :destroy + end + + NEAR_CARD_LIMIT_THRESHOLD = 100 + + def override_limits(card_count:) + if overridden_limits + overridden_limits.update(card_count: card_count) + else + create_overridden_limits(card_count: card_count) + end + end + + def billed_cards_count + overridden_limits&.card_count || cards_count + end + + def nearing_plan_cards_limit? + plan.limit_cards? && (plan.card_limit - billed_cards_count) < NEAR_CARD_LIMIT_THRESHOLD + end + + def exceeding_card_limit? + plan.limit_cards? && billed_cards_count > plan.card_limit + end + + def reset_overridden_limits + overridden_limits&.destroy + reload_overridden_limits + end +end diff --git a/saas/app/models/account/overridden_limits.rb b/saas/app/models/account/overridden_limits.rb new file mode 100644 index 000000000..cdf3c51ec --- /dev/null +++ b/saas/app/models/account/overridden_limits.rb @@ -0,0 +1,4 @@ +# To ease testing of limits +class Account::OverriddenLimits < SaasRecord + belongs_to :account +end diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 58fb60891..52479f0b1 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -6,7 +6,7 @@ <% if Current.account.exceeding_card_limit? %>

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

<% else %> -

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

+

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 4245c8cd5..1a2f90515 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -1,14 +1,33 @@ -

Edit Account <%= @account.external_account_id %>

+
+
+

Edit Account <%= @account.external_account_id %>

-

Name: <%= @account.name %>

+
+
+
Name:
+
<%= @account.name %>
+
+
+
Actual card count:
+
<%= @account.cards_count %>
+
+
-<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id) do |form| %> -

- <%= form.label :cards_count %> - <%= form.number_field :cards_count %> -

+ <%= 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.submit "Update" %> -<% end %> + + <% end %> -

<%= link_to "Back", saas.admin_accounts_path %>

+ <% if @account.overridden_limits %> + <%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %> + <% end %> + + <%= link_to "Back to accounts", saas.admin_accounts_path, class: "btn btn--plain txt-link" %> +
+
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 7b34f7404..e40aa9fb5 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.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") %>. <% else %> - This account has used <%= Current.account.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 <% end %>
<% end %> diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 23ee39507..7f9d36465 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -5,6 +5,8 @@ Fizzy::Saas::Engine.routes.draw do mount Audits1984::Engine, at: "/console" get "stats", to: "stats#show" resource :account_search, only: :create - resources :accounts + resources :accounts do + resource :overridden_limits, only: :destroy + end end end diff --git a/saas/db/migrate/20251215140000_create_account_overridden_limits.rb b/saas/db/migrate/20251215140000_create_account_overridden_limits.rb new file mode 100644 index 000000000..7e8d9da42 --- /dev/null +++ b/saas/db/migrate/20251215140000_create_account_overridden_limits.rb @@ -0,0 +1,10 @@ +class CreateAccountOverriddenLimits < ActiveRecord::Migration[8.2] + def change + create_table :account_overridden_limits, id: :uuid do |t| + t.references :account, null: false, type: :uuid, index: { unique: true } + t.integer :card_count + + t.timestamps + end + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 190a5e8f7..edfead6ad 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,15 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_03_144630) do +ActiveRecord::Schema[8.2].define(version: 2025_12_15_140000) 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.integer "card_count" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_overridden_limits_on_account_id", unique: true + end + create_table "account_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "cancel_at" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 512e4884e..532f0a384 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -127,8 +127,8 @@ module Fizzy end config.to_prepare do - ::Account.include(Account::Billing) - ::Signup.prepend(Fizzy::Saas::Signup) + ::Account.include Account::Billing, Account::Limited + ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) Cards::PublishesController.include(Card::LimitedPublishing) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index 6b423e3c3..ada09dabd 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -1,13 +1,6 @@ require "rake/testtask" namespace :test do - # 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: :environment) do |t| t.libs << "test" diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb index a6c815bcf..0b830919a 100644 --- a/saas/test/controllers/admin/accounts_controller_test.rb +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -1,10 +1,6 @@ require "test_helper" class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest - def saas - Fizzy::Saas::Engine.routes.url_helpers - end - test "staff can access index" do sign_in_as :david @@ -34,15 +30,15 @@ class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "staff can update cards_count" do + test "staff can override card count" do sign_in_as :david untenanted do - patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 500 } } + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } } assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) end - assert_equal 500, accounts(:"37s").reload.cards_count + assert_equal 500, accounts(:"37s").reload.billed_cards_count end test "non-staff cannot access accounts" do diff --git a/saas/test/controllers/admin/overridden_limits_controller_test.rb b/saas/test/controllers/admin/overridden_limits_controller_test.rb new file mode 100644 index 000000000..b660e08b0 --- /dev/null +++ b/saas/test/controllers/admin/overridden_limits_controller_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class Admin::OverriddenLimitsControllerTest < ActionDispatch::IntegrationTest + test "staff can reset overridden limits" do + sign_in_as :david + account = accounts(:"37s") + + # First set an override + account.override_limits(card_count: 500) + assert_equal 500, account.reload.billed_cards_count + + # Then reset it + untenanted do + delete saas.admin_account_overridden_limits_path(account.external_account_id) + assert_redirected_to saas.edit_admin_account_path(account.external_account_id) + end + + # Verify override was removed + assert_nil account.reload.overridden_limits + assert_equal account.cards_count, account.billed_cards_count + end +end diff --git a/saas/test/controllers/admin/stats_controller_test.rb b/saas/test/controllers/admin/stats_controller_test.rb index 9fa0c8b3f..d7de964c1 100644 --- a/saas/test/controllers/admin/stats_controller_test.rb +++ b/saas/test/controllers/admin/stats_controller_test.rb @@ -1,10 +1,6 @@ require "test_helper" class Admin::StatsControllerTest < ActionDispatch::IntegrationTest - def saas - Fizzy::Saas::Engine.routes.url_helpers - end - test "staff can access stats" do sign_in_as :david diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 086f60807..3969f18b4 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -15,35 +15,4 @@ class Account::BillingTest < ActiveSupport::TestCase account.subscription.update!(status: "active") assert_equal account.subscription, account.active_subscription end - - test "detect nearing card limit" do - # Paid plans are never limited - accounts(:"37s").update_column(:cards_count, 1_000_000) - assert_not accounts(:"37s").nearing_plan_cards_limit? - - # Free plan not near limit - accounts(:initech).update_column(:cards_count, 899) - assert_not accounts(:initech).nearing_plan_cards_limit? - - # Free plan near limit - accounts(:initech).update_column(:cards_count, 900) - assert_not accounts(:initech).nearing_plan_cards_limit? - - accounts(:initech).update_column(:cards_count, 901) - assert accounts(:initech).nearing_plan_cards_limit? - end - - test "detect exceeding card limit" do - # Paid plans are never limited - accounts(:"37s").update_column(:cards_count, 1_000_000) - assert_not accounts(:"37s").exceeding_card_limit? - - # Free plan under limit - accounts(:initech).update_column(:cards_count, 999) - assert_not accounts(:initech).exceeding_card_limit? - - # Free plan over limit - accounts(:initech).update_column(:cards_count, 1001) - assert accounts(:initech).exceeding_card_limit? - end end diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb new file mode 100644 index 000000000..ad982203c --- /dev/null +++ b/saas/test/models/account/limited_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class Account::LimitedTest < ActiveSupport::TestCase + test "detect nearing card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").nearing_plan_cards_limit? + + # Free plan not near limit + accounts(:initech).update_column(:cards_count, 899) + assert_not accounts(:initech).nearing_plan_cards_limit? + + # Free plan near limit + accounts(:initech).update_column(:cards_count, 900) + assert_not accounts(:initech).nearing_plan_cards_limit? + + accounts(:initech).update_column(:cards_count, 901) + assert accounts(:initech).nearing_plan_cards_limit? + end + + test "detect exceeding card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").exceeding_card_limit? + + # Free plan under limit + accounts(:initech).update_column(:cards_count, 999) + assert_not accounts(:initech).exceeding_card_limit? + + # Free plan over limit + accounts(:initech).update_column(:cards_count, 1001) + assert accounts(:initech).exceeding_card_limit? + end + + test "override limits" do + account = accounts(:initech) + account.update_column(:cards_count, 1001) + + assert account.exceeding_card_limit? + assert_equal 1001, account.billed_cards_count + + account.override_limits card_count: 500 + assert_not account.exceeding_card_limit? + assert_equal 500, account.billed_cards_count + assert_equal 1001, account.cards_count # original unchanged + + account.reset_overridden_limits + assert account.exceeding_card_limit? + assert_equal 1001, account.billed_cards_count + end +end From 8dd31e363587d117d0fe26f0f4277dec95c2a3d0 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 16:44:53 +0100 Subject: [PATCH 125/274] Add system to comp accounts --- .../admin/billing_waivers_controller.rb | 13 ++++++++ saas/app/models/account/billing.rb | 25 ++++++++++++++- saas/app/models/account/billing_waiver.rb | 7 ++++ saas/app/models/account/subscription.rb | 2 +- saas/app/models/plan.rb | 4 +-- .../settings/_subscription_panel.html.erb | 2 +- saas/app/views/admin/accounts/edit.html.erb | 6 ++++ saas/config/routes.rb | 1 + ...15160000_create_account_billing_waivers.rb | 9 ++++++ saas/db/saas_schema.rb | 9 +++++- .../admin/billing_waivers_controller_test.rb | 32 +++++++++++++++++++ saas/test/models/account/billing_test.rb | 15 ++++++++- saas/test/models/account/limited_test.rb | 25 +++++++++++++++ 13 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 saas/app/controllers/admin/billing_waivers_controller.rb create mode 100644 saas/app/models/account/billing_waiver.rb create mode 100644 saas/db/migrate/20251215160000_create_account_billing_waivers.rb create mode 100644 saas/test/controllers/admin/billing_waivers_controller_test.rb diff --git a/saas/app/controllers/admin/billing_waivers_controller.rb b/saas/app/controllers/admin/billing_waivers_controller.rb new file mode 100644 index 000000000..8895ad399 --- /dev/null +++ b/saas/app/controllers/admin/billing_waivers_controller.rb @@ -0,0 +1,13 @@ +class Admin::BillingWaiversController < AdminController + include Admin::AccountScoped + + def create + @account.comp + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account comped" + end + + def destroy + @account.uncomp + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped" + end +end \ No newline at end of file diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 2ef42edb7..5b52dd836 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -3,6 +3,7 @@ module Account::Billing included do has_one :subscription, class_name: "Account::Subscription", dependent: :destroy + has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy end def plan @@ -10,10 +11,32 @@ module Account::Billing end def active_subscription - subscription if subscription&.active? + if comped? + comped_subscription + else + subscription if subscription&.active? + end end def subscribed? subscription.present? end + + def comped? + billing_waiver.present? + end + + def comp + create_billing_waiver unless billing_waiver + end + + def uncomp + billing_waiver&.destroy + reload_billing_waiver + end + + private + def comped_subscription + @comped_subscription ||= billing_waiver&.subscription + end end diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb new file mode 100644 index 000000000..c1968522a --- /dev/null +++ b/saas/app/models/account/billing_waiver.rb @@ -0,0 +1,7 @@ +class Account::BillingWaiver < SaasRecord + belongs_to :account + + def subscription + @subscription ||= Account::Subscription.new(plan_key: Plan.paid.key) + end +end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index cc4989343..937d11bcc 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -8,7 +8,7 @@ class Account::Subscription < SaasRecord delegate :paid?, to: :plan def plan - Plan.find(plan_key) + @plan ||= Plan.find(plan_key) end def to_be_canceled? diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 77a2cfbc7..835e94472 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -12,11 +12,11 @@ class Plan end def free - @free ||= all.find(&:free?) + @free ||= find(:free_v1) end def paid - @paid ||= all.find(&:paid?) + @paid ||= find(:monthly_v1) end def find(key) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 52479f0b1..55fc2f63f 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -1,4 +1,4 @@ -<% if Current.user.admin? %> +<% if Current.user.admin? && !Current.account.comped? %>

Subscription

diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 1a2f90515..49c99773d 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -28,6 +28,12 @@ <%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %> <% end %> + <% if @account.comped? %> + <%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %> + <% else %> + <%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--positive" %> + <% end %> + <%= link_to "Back to accounts", saas.admin_accounts_path, class: "btn btn--plain txt-link" %>
diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 7f9d36465..ece9fef17 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -7,6 +7,7 @@ Fizzy::Saas::Engine.routes.draw do resource :account_search, only: :create resources :accounts do resource :overridden_limits, only: :destroy + resource :billing_waiver, only: [ :create, :destroy ] end end end diff --git a/saas/db/migrate/20251215160000_create_account_billing_waivers.rb b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb new file mode 100644 index 000000000..2cc9023d1 --- /dev/null +++ b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb @@ -0,0 +1,9 @@ +class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2] + def change + create_table :account_billing_waivers, id: :uuid do |t| + t.references :account, null: false, type: :uuid, index: { unique: true } + + t.timestamps + end + end +end \ No newline at end of file diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index edfead6ad..15dea28fc 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_15_140000) do +ActiveRecord::Schema[8.2].define(version: 2025_12_15_160000) 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 + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_billing_waivers_on_account_id", unique: true + end + 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.integer "card_count" diff --git a/saas/test/controllers/admin/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb new file mode 100644 index 000000000..ad2f76721 --- /dev/null +++ b/saas/test/controllers/admin/billing_waivers_controller_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class Admin::BillingWaiversControllerTest < ActionDispatch::IntegrationTest + test "staff can comp an account" do + sign_in_as :david + account = accounts(:"37s") + + assert_not account.comped? + + untenanted do + post saas.admin_account_billing_waiver_path(account.external_account_id) + assert_redirected_to saas.edit_admin_account_path(account.external_account_id) + end + + assert account.reload.comped? + end + + test "staff can uncomp an account" do + sign_in_as :david + account = accounts(:"37s") + account.comp + + assert account.comped? + + untenanted do + delete saas.admin_account_billing_waiver_path(account.external_account_id) + assert_redirected_to saas.edit_admin_account_path(account.external_account_id) + end + + assert_not account.reload.comped? + end +end \ No newline at end of file diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 3969f18b4..cdb7c3fa9 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -2,7 +2,7 @@ require "test_helper" class Account::BillingTest < ActiveSupport::TestCase test "active subscription" do - account = Account.create!(name: "Test") + account = accounts(:initech) # No subscription assert_nil account.active_subscription @@ -15,4 +15,17 @@ class Account::BillingTest < ActiveSupport::TestCase account.subscription.update!(status: "active") assert_equal account.subscription, account.active_subscription end + + test "comped account" do + account = accounts(:"37s") + + assert_not account.comped? + + account.comp + assert account.comped? + + # Calling comp again does not create duplicate + account.comp + assert_equal 1, Account::BillingWaiver.where(account: account).count + end end diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index ad982203c..335d6a37c 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -48,4 +48,29 @@ class Account::LimitedTest < ActiveSupport::TestCase assert account.exceeding_card_limit? assert_equal 1001, account.billed_cards_count end + + test "comped accounts are never limited" do + account = accounts(:initech) + account.update_column(:cards_count, 1_000_000) + + assert account.exceeding_card_limit? + assert account.nearing_plan_cards_limit? + + account.comp + + assert_not account.exceeding_card_limit? + assert_not account.nearing_plan_cards_limit? + end + + test "uncomping an account restores limits" do + account = accounts(:initech) + account.update_column(:cards_count, 1_000_000) + account.comp + + assert_not account.exceeding_card_limit? + + account.uncomp + + assert account.exceeding_card_limit? + end end From 3395b8d6ed2a6637e289af3cf0c6fd945ad161dd Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 18:26:42 +0100 Subject: [PATCH 126/274] Grab and show the next amount to pay from Stripe --- saas/app/controllers/stripe/webhooks_controller.rb | 12 +++++++++++- saas/app/helpers/subscriptions_helper.rb | 2 +- saas/app/models/account/subscription.rb | 4 ++++ ...t_amount_due_in_cents_to_account_subscriptions.rb | 5 +++++ saas/db/saas_schema.rb | 3 ++- .../controllers/stripe/webhooks_controller_test.rb | 9 +++++++-- 6 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 14c50c74d..22d8d4e0c 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -48,7 +48,8 @@ class Stripe::WebhooksController < ApplicationController stripe_subscription_id: stripe_subscription.id, 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 + cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil, + next_amount_due_in_cents: next_amount_due_for(stripe_subscription) } yield subscription_properties if block_given? @@ -66,4 +67,13 @@ class Stripe::WebhooksController < ApplicationController timestamp = stripe_subscription.items.data.first&.current_period_end Time.at(timestamp) if timestamp end + + def next_amount_due_for(stripe_subscription) + return nil if stripe_subscription.status == "canceled" + + preview = Stripe::Invoice.create_preview(customer: stripe_subscription.customer, subscription: stripe_subscription.id) + preview.amount_due + rescue Stripe::InvalidRequestError + nil + end end diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index 32dd45402..2c05b68eb 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -5,7 +5,7 @@ module SubscriptionsHelper elsif subscription.canceled? "Your Fizzy subscription ended on" else - "Your next payment of $#{ subscription.plan.price } will be billed on".html_safe + "Your next payment of #{ number_to_currency(subscription.next_amount_due) } will be billed on".html_safe end end end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 937d11bcc..0eac71b02 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -14,4 +14,8 @@ class Account::Subscription < SaasRecord def to_be_canceled? active? && cancel_at.present? end + + def next_amount_due + next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price + end end diff --git a/saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb b/saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb new file mode 100644 index 000000000..28838b314 --- /dev/null +++ b/saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb @@ -0,0 +1,5 @@ +class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2] + def change + add_column :account_subscriptions, :next_amount_due_in_cents, :integer + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 15dea28fc..59c78f4cb 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_160000) do +ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) 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 @@ -31,6 +31,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_15_160000) do t.datetime "cancel_at" t.datetime "created_at", null: false t.datetime "current_period_end" + t.integer "next_amount_due_in_cents" t.string "plan_key" t.string "status" t.string "stripe_customer_id", null: false diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb index c1c8ac4d0..92f6b7508 100644 --- a/saas/test/controllers/stripe/webhooks_controller_test.rb +++ b/saas/test/controllers/stripe/webhooks_controller_test.rb @@ -29,6 +29,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest Stripe::Webhook.stubs(:construct_event).returns(event) Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) + Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999)) post stripe_webhooks_path @@ -38,7 +39,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest assert_equal "active", @subscription.status end - test "subscription updated changes status" do + test "subscription updated changes status and syncs next amount due" do @subscription.update!(stripe_subscription_id: "sub_123", status: "active") stripe_sub = OpenStruct.new( @@ -53,11 +54,14 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest Stripe::Webhook.stubs(:construct_event).returns(event) Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) + Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999)) post stripe_webhooks_path assert_response :ok - assert_equal "past_due", @subscription.reload.status + @subscription.reload + assert_equal "past_due", @subscription.status + assert_equal 1999, @subscription.next_amount_due_in_cents end test "subscription deleted cancels subscription" do @@ -82,6 +86,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest @subscription.reload assert_equal "canceled", @subscription.status assert_nil @subscription.stripe_subscription_id + assert_nil @subscription.next_amount_due_in_cents end private From f68aa0d94792231c3619bd2669f997383e5bbaf8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 21:49:23 +0100 Subject: [PATCH 127/274] Enable tax id collection --- saas/app/controllers/account/subscriptions_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 1e4c6d87e..7abe1dec1 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -14,6 +14,7 @@ class Account::SubscriptionsController < ApplicationController cancel_url: account_subscription_url, metadata: { account_id: Current.account.id, plan_key: Plan.paid.key }, automatic_tax: { enabled: true }, + tax_id_collection: { enabled: true }, billing_address_collection: "required", customer_update: { address: "auto" } From c0050415f54286da46babf92be073efab61cfe49 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 21:59:37 +0100 Subject: [PATCH 128/274] This is needed for vat id collection --- saas/app/controllers/account/subscriptions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 7abe1dec1..129de9c71 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -16,7 +16,7 @@ class Account::SubscriptionsController < ApplicationController automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, billing_address_collection: "required", - customer_update: { address: "auto" } + customer_update: { address: "auto", name: "auto" } redirect_to session.url, allow_other_host: true end From 973b4e4aed63fbef1cdeba2aa5bea4d6c7a7843d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 08:50:54 +0100 Subject: [PATCH 129/274] Rename param to remove redundant bit Co-authored-by: Matt Almeida --- saas/app/controllers/stripe/webhooks_controller.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 22d8d4e0c..a111a1931 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -43,7 +43,7 @@ class Stripe::WebhooksController < ApplicationController def sync_subscription(stripe_subscription_id) stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id) - if subscription = find_subscription_by_customer(stripe_subscription.customer) + if subscription = find_subscription_by_stripe_customer(stripe_subscription.customer) subscription_properties = { stripe_subscription_id: stripe_subscription.id, status: stripe_subscription.status, @@ -59,8 +59,8 @@ class Stripe::WebhooksController < ApplicationController end end - def find_subscription_by_customer(customer_id) - Account::Subscription.find_by(stripe_customer_id: customer_id) + def find_subscription_by_stripe_customer(id) + Account::Subscription.find_by(stripe_customer_id: id) end def current_period_end_for(stripe_subscription) From 20b5e57dd35baaefa91be4dfae1ee5b608b191d5 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 09:40:33 +0100 Subject: [PATCH 130/274] Prefer if/else over early return --- saas/app/controllers/stripe/webhooks_controller.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index a111a1931..bf00fc23f 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -4,12 +4,12 @@ class Stripe::WebhooksController < ApplicationController skip_before_action :verify_authenticity_token def create - event = verify_webhook_signature - return head :bad_request unless event - - dispatch_stripe_event(event) - - head :ok + if event = verify_webhook_signature + dispatch_stripe_event(event) + head :ok + else + head :bad_request + end end private From e47657759fcf60ac16dc8f10792e302b8b2a8a12 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:08:25 +0100 Subject: [PATCH 131/274] More concise --- saas/app/models/account/limited.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index 6fed72e9e..eaf391ca1 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -8,11 +8,7 @@ module Account::Limited NEAR_CARD_LIMIT_THRESHOLD = 100 def override_limits(card_count:) - if overridden_limits - overridden_limits.update(card_count: card_count) - else - create_overridden_limits(card_count: card_count) - end + (overridden_limits || build_overridden_limits).update!(card_count:) end def billed_cards_count From b39a9ecab40afb3d2db80d481763f9fbd13f16a3 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:22:52 +0100 Subject: [PATCH 132/274] Make active_subscription a private method --- saas/app/models/account/billing.rb | 16 ++++++++-------- saas/test/models/account/billing_test.rb | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 5b52dd836..7d51b11a9 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -10,14 +10,6 @@ module Account::Billing active_subscription&.plan || Plan.free end - def active_subscription - if comped? - comped_subscription - else - subscription if subscription&.active? - end - end - def subscribed? subscription.present? end @@ -36,6 +28,14 @@ module Account::Billing end private + def active_subscription + if comped? + comped_subscription + else + subscription if subscription&.active? + end + end + def comped_subscription @comped_subscription ||= billing_waiver&.subscription end diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index cdb7c3fa9..7283f47da 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -1,19 +1,19 @@ require "test_helper" class Account::BillingTest < ActiveSupport::TestCase - test "active subscription" do + test "plan reflects active subscription" do account = accounts(:initech) # No subscription - assert_nil account.active_subscription + assert_equal Plan.free, account.plan # Subscription but it is not active account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test") - assert_nil account.active_subscription + assert_equal Plan.free, account.plan # Active subscription exists account.subscription.update!(status: "active") - assert_equal account.subscription, account.active_subscription + assert_equal Plan.paid, account.plan end test "comped account" do From 71369de38df2b2ce393815f4a26fe5d4d05c48d7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:31:25 +0100 Subject: [PATCH 133/274] Extract method --- saas/app/models/account/limited.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index eaf391ca1..f7eb3cbc1 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -16,7 +16,7 @@ module Account::Limited end def nearing_plan_cards_limit? - plan.limit_cards? && (plan.card_limit - billed_cards_count) < NEAR_CARD_LIMIT_THRESHOLD + plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD end def exceeding_card_limit? @@ -27,4 +27,9 @@ module Account::Limited overridden_limits&.destroy reload_overridden_limits end + + private + def remaining_cards_count + plan.card_limit - billed_cards_count + end end From 8d11c62b7c69bbb585de1e632182a9cebbe71535 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:34:03 +0100 Subject: [PATCH 134/274] Rename to stripe_session to avoid confusions --- saas/app/controllers/account/subscriptions_controller.rb | 2 +- saas/app/views/account/subscriptions/show.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 129de9c71..8ddfb4b7a 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -23,7 +23,7 @@ class Account::SubscriptionsController < ApplicationController private def set_stripe_session - @session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] + @stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] end def find_or_create_stripe_customer diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb index 64987b5d0..8d29a379e 100644 --- a/saas/app/views/account/subscriptions/show.html.erb +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -1,6 +1,6 @@ <% @page_title = "Thank you" %> -<% if @session&.payment_status == "paid" %> +<% if @stripe_session&.payment_status == "paid" %>

Thanks for buying Fizzy!

Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.

From bf045b99070f4cddc69ed675af2969c8e9164560 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:37:23 +0100 Subject: [PATCH 135/274] Format --- .../db/migrate/20251215160000_create_account_billing_waivers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/db/migrate/20251215160000_create_account_billing_waivers.rb b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb index 2cc9023d1..8f3732c09 100644 --- a/saas/db/migrate/20251215160000_create_account_billing_waivers.rb +++ b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb @@ -6,4 +6,4 @@ class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2] t.timestamps end end -end \ No newline at end of file +end From 2671e52821f988994679096e4cbfe11b4e010bca Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:44:19 +0100 Subject: [PATCH 136/274] Move presentation method to a regular view helper --- saas/app/helpers/subscriptions_helper.rb | 4 ++++ saas/app/models/plan.rb | 4 ---- .../app/views/account/settings/_subscription_panel.html.erb | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index 2c05b68eb..a1c4c07d9 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -1,4 +1,8 @@ module SubscriptionsHelper + def plan_storage_limit(plan) + number_to_human_size(plan.storage_limit).delete(" ") + end + def subscription_period_end_action(subscription) if subscription.to_be_canceled? "Your Fizzy subscription ends on" diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 835e94472..7ef7f922e 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -47,8 +47,4 @@ class Plan def limit_cards? card_limit != Float::INFINITY end - - def formatted_storage_limit - ActionController::Base.helpers.number_to_human_size(storage_limit).delete(" ") - end end diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 55fc2f63f..44949e022 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -9,18 +9,18 @@

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

+

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.

- + <% else %>

Thank you for buying Fizzy

<% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - + <% end %> <% end %> From 5b2fa6b74a563863ecf8b8a65bba9a00a6c8bc18 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 11:11:49 +0100 Subject: [PATCH 137/274] Remove nested if --- saas/app/models/account/billing.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 7d51b11a9..dada43721 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -31,8 +31,8 @@ module Account::Billing def active_subscription if comped? comped_subscription - else - subscription if subscription&.active? + elsif subscription&.active? + subscription end end From 3c197debd205897f3229865600b63a7a385b76e3 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 11:14:17 +0100 Subject: [PATCH 138/274] Format --- saas/app/controllers/admin/billing_waivers_controller.rb | 2 +- saas/exe/stripe-dev | 4 ++-- .../controllers/accounts/subscriptions_controller_test.rb | 1 - .../test/controllers/admin/billing_waivers_controller_test.rb | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/saas/app/controllers/admin/billing_waivers_controller.rb b/saas/app/controllers/admin/billing_waivers_controller.rb index 8895ad399..713891069 100644 --- a/saas/app/controllers/admin/billing_waivers_controller.rb +++ b/saas/app/controllers/admin/billing_waivers_controller.rb @@ -10,4 +10,4 @@ class Admin::BillingWaiversController < AdminController @account.uncomp redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped" end -end \ No newline at end of file +end diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index c2aea61a4..2033385ff 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -44,8 +44,8 @@ File.write(LOG_FILE, "") # Start stripe listen in background pid = spawn( "stripe", "listen", "--forward-to", "localhost:3006/stripe/webhooks", - out: [LOG_FILE, "a"], - err: [LOG_FILE, "a"] + out: [ LOG_FILE, "a" ], + err: [ LOG_FILE, "a" ] ) Process.detach(pid) diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb index 9cf5d86bc..d98ac1d91 100644 --- a/saas/test/controllers/accounts/subscriptions_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -43,5 +43,4 @@ class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest post account_subscription_path assert_response :forbidden end - end diff --git a/saas/test/controllers/admin/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb index ad2f76721..8f7d14199 100644 --- a/saas/test/controllers/admin/billing_waivers_controller_test.rb +++ b/saas/test/controllers/admin/billing_waivers_controller_test.rb @@ -29,4 +29,4 @@ class Admin::BillingWaiversControllerTest < ActionDispatch::IntegrationTest assert_not account.reload.comped? end -end \ No newline at end of file +end From b0f9960b8a0fae6a89f91e207a56b842f57d0f36 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 16 Dec 2025 13:36:40 +0100 Subject: [PATCH 139/274] Raise the limit to 100 --- app/models/assignment.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 02f81bc25..75ffde959 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -1,5 +1,5 @@ class Assignment < ApplicationRecord - LIMIT = 10 + LIMIT = 100 belongs_to :account, default: -> { card.account } belongs_to :card, touch: true From 14f337739df06de6feff6bad85d810a88cd53fe4 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 16 Dec 2025 13:28:49 +0000 Subject: [PATCH 140/274] Update Thruster --- Gemfile.lock | 8 ++++---- Gemfile.saas.lock | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5a1eeb3cb..57315b541 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -435,10 +435,10 @@ GEM railties (>= 6.0.0) stringio (3.1.9) thor (1.4.0) - thruster (0.1.16) - thruster (0.1.16-aarch64-linux) - thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-linux) + thruster (0.1.17) + thruster (0.1.17-aarch64-linux) + thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-linux) timeout (0.4.4) trilogy (2.9.0) tsort (0.2.0) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index e4a604416..bac734425 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -559,10 +559,10 @@ GEM railties (>= 6.0.0) stringio (3.1.9) thor (1.4.0) - thruster (0.1.16) - thruster (0.1.16-aarch64-linux) - thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-linux) + thruster (0.1.17) + thruster (0.1.17-aarch64-linux) + thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-linux) timeout (0.4.4) trilogy (2.9.0) tsort (0.2.0) From b75d2ae6d843ecac65f3a8e12e37b81921568520 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sun, 14 Dec 2025 12:48:53 -0500 Subject: [PATCH 141/274] Validate User name presence and handle blank names gracefully Addresses an issue where User#familiar_name assumed `name` was always present, potentially raising an exception during view rendering. Now User validates name presence, and User#familiar_name handles blank strings without error, in case any existing invalid records exist. --- app/models/user.rb | 2 ++ app/models/user/named.rb | 2 +- .../signup/completions_controller_test.rb | 7 +++++-- test/models/user_test.rb | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 6503881a3..e16d70670 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,8 @@ class User < ApplicationRecord belongs_to :account belongs_to :identity, optional: true + validates :name, presence: true + has_many :comments, inverse_of: :creator, dependent: :destroy has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy diff --git a/app/models/user/named.rb b/app/models/user/named.rb index aa4f9b585..d052a79ad 100644 --- a/app/models/user/named.rb +++ b/app/models/user/named.rb @@ -19,7 +19,7 @@ module User::Named def familiar_name names = name.split - return name if names.length == 1 + return name if names.length <= 1 "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}" end end diff --git a/test/controllers/signup/completions_controller_test.rb b/test/controllers/signup/completions_controller_test.rb index 1b788ce31..a1319d868 100644 --- a/test/controllers/signup/completions_controller_test.rb +++ b/test/controllers/signup/completions_controller_test.rb @@ -29,7 +29,7 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest assert_response :redirect, "Valid params should redirect" end - test "create with invalid params" do + test "create with blank name" do untenanted do post signup_completion_path, params: { signup: { @@ -38,6 +38,9 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest } end - assert_response :unprocessable_entity, "Invalid params should return unprocessable entity" + assert_response :unprocessable_entity + assert_select ".txt-negative" do + assert_select "li", text: "Full name can't be blank" + end end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 645e3a386..9e5d2bde9 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -39,6 +39,27 @@ class UserTest < ActiveSupport::TestCase assert_equal "ÉLH", User.new(name: "Éva-Louise Hernández").initials end + test "name methods handle blank names gracefully" do + user = User.new(name: "") + assert_equal "", user.familiar_name + assert_nil user.first_name + assert_nil user.last_name + assert_equal "", user.initials + end + + test "validates name presence" do + user = User.new(account: accounts("37s"), role: "member", name: "") + assert_not user.valid? + assert_includes user.errors[:name], "can't be blank" + + user.name = " " + assert_not user.valid? + assert_includes user.errors[:name], "can't be blank" + + user.name = "Victor Cooper" + assert user.valid? + end + test "setup?" do user = users(:kevin) From ee80b87c8c022d5540168d8af0fb8782856bc7fc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 10 Dec 2025 16:08:38 +0100 Subject: [PATCH 142/274] Add billing system with Stripe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Jason Zimdars --- Gemfile.saas | 2 +- Gemfile.saas.lock | 4 +- app/assets/stylesheets/web/animation.css | 9 ++ app/assets/stylesheets/web/card-perma.css | 2 +- app/assets/stylesheets/web/utilities.css | 17 +++ app/controllers/admin/stats_controller.rb | 20 --- app/models/admin.rb | 2 + app/views/account/settings/show.html.erb | 2 + app/views/admin/stats/show.html.erb | 126 ------------------ app/views/cards/_container.html.erb | 20 +-- .../cards/container/footer/_create.html.erb | 19 +++ .../cards/container/footer/_draft.html.erb | 14 -- app/views/layouts/shared/_head.html.erb | 1 + config/routes.rb | 3 +- .../admin/stats_controller_test.rb | 23 ---- test/fixtures/accesses.yml | 6 + test/fixtures/cards.yml | 11 ++ test/models/card_test.rb | 2 +- 18 files changed, 86 insertions(+), 197 deletions(-) delete mode 100644 app/controllers/admin/stats_controller.rb create mode 100644 app/models/admin.rb delete mode 100644 app/views/admin/stats/show.html.erb create mode 100644 app/views/cards/container/footer/_create.html.erb delete mode 100644 app/views/cards/container/footer/_draft.html.erb delete mode 100644 test/controllers/admin/stats_controller_test.rb diff --git a/Gemfile.saas b/Gemfile.saas index 8681a31f6..2366e80aa 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -3,8 +3,8 @@ eval_gemfile "Gemfile" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } -# SaaS-only functionality gem "activeresource", require: "active_resource" +gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" gem "fizzy-saas", bc: "fizzy-saas" gem "console1984", bc: "console1984" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index bac734425..bd811d9d6 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143 + revision: 8eaa692316bb27ed73bd628b4735082585498ca0 specs: fizzy-saas (0.1.0) audits1984 @@ -558,6 +558,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.9) + stripe (18.0.1) thor (1.4.0) thruster (0.1.17) thruster (0.1.17-aarch64-linux) @@ -684,6 +685,7 @@ DEPENDENCIES solid_queue (~> 1.2) sqlite3 (>= 2.0) stimulus-rails + stripe (~> 18.0) thruster trilogy (~> 2.9) turbo-rails diff --git a/app/assets/stylesheets/web/animation.css b/app/assets/stylesheets/web/animation.css index d6f05efa9..b38c4ce82 100644 --- a/app/assets/stylesheets/web/animation.css +++ b/app/assets/stylesheets/web/animation.css @@ -71,6 +71,15 @@ 33% { background-color: var(--color-border-darker); scale: 1; } } + @keyframes wiggle { + 0% { transform: rotate(0deg); } + 20% { transform: rotate(3deg); } + 40% { transform: rotate(-3deg); } + 60% { transform: rotate(3deg); } + 80% { transform: rotate(-3deg); } + 100% { transform: rotate(0deg); } + } + @keyframes wobble { 0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); } 15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; } diff --git a/app/assets/stylesheets/web/card-perma.css b/app/assets/stylesheets/web/card-perma.css index 3132d886f..622c9c198 100644 --- a/app/assets/stylesheets/web/card-perma.css +++ b/app/assets/stylesheets/web/card-perma.css @@ -277,7 +277,7 @@ border-radius: 99rem; } - .btn:not(.popup__btn, .btn--plain, .btn--reversed) { + .btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) { --btn-background: var(--card-color); --btn-color: var(--color-ink-inverted); } diff --git a/app/assets/stylesheets/web/utilities.css b/app/assets/stylesheets/web/utilities.css index 9add37264..02ed8ee19 100644 --- a/app/assets/stylesheets/web/utilities.css +++ b/app/assets/stylesheets/web/utilities.css @@ -184,6 +184,23 @@ .fill-highlight { background-color: var(--color-highlight); } .fill-transparent { background-color: transparent; } + .fill-highlighter { + display: inline-block; + position: relative; + z-index: 1; + + &::before { + background-color: var(--color-highlight); + border-radius: 0.2em; + content: ""; + inset-block: 0; + inset-inline: -0.1em; + position: absolute; + transform: skewX(-10deg) rotate(1deg); + z-index: -1; + } + } + .translucent { opacity: var(--opacity, 0.66); } /* Borders */ diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb deleted file mode 100644 index 6d508fc2c..000000000 --- a/app/controllers/admin/stats_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -class Admin::StatsController < AdminController - layout "public" - - def show - @accounts_total = Account.count - @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count - @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count - - @identities_total = Identity.count - @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count - @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count - - @top_accounts = Account - .where("cards_count > 0") - .order(cards_count: :desc) - .limit(20) - - @recent_accounts = Account.order(created_at: :desc).limit(10) - end -end diff --git a/app/models/admin.rb b/app/models/admin.rb new file mode 100644 index 000000000..53608e3eb --- /dev/null +++ b/app/models/admin.rb @@ -0,0 +1,2 @@ +module Admin +end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 273a07b9a..fc5e799b3 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -20,3 +20,5 @@ <%= render "account/settings/export" %>
+ +<%= 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 143/274] 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 144/274] 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 145/274] 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 146/274] 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 147/274] 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 148/274] 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 149/274] 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 150/274] 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 151/274] 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 152/274] 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 153/274] 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 154/274] 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 172/274] 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 173/274] 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 174/274] 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 175/274] 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 176/274] 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 177/274] 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 178/274] 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 179/274] 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 180/274] 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 181/274] 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 182/274] 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 183/274] 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 184/274] 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 185/274] 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 186/274] 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 187/274] 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 188/274] 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 189/274] 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 190/274] 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 191/274] 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 192/274] 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 193/274] 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 194/274] 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 195/274] 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 196/274] 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 197/274] 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 198/274] 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 199/274] 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 200/274] 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 201/274] 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 202/274] 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 203/274] 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 204/274] 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 205/274] 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 206/274] 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 207/274] 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 @@

    -
    +
    <%= render "cards/display/preview/meta", card: card, preview: true %> + <%= render "cards/display/preview/comments", card: card %> <%= render "cards/display/common/background", card: card %>
    diff --git a/app/views/cards/display/preview/_comments.html.erb b/app/views/cards/display/preview/_comments.html.erb new file mode 100644 index 000000000..7cca0d106 --- /dev/null +++ b/app/views/cards/display/preview/_comments.html.erb @@ -0,0 +1,7 @@ +<% comments = card.comments.by_user %> +<% if comments.any? %> +
    + <%= icon_tag "comment" %> + <%= comments.count %> +
    +<% end %> From 60dc6927fe26ec6eed8c572733ae092736d4cc3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 18 Dec 2025 17:34:25 +0000 Subject: [PATCH 211/274] Remove attachment detection --- .../migrations/split-sibling-paragraphs-with-p-br.rb | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/script/migrations/split-sibling-paragraphs-with-p-br.rb b/script/migrations/split-sibling-paragraphs-with-p-br.rb index 42778292f..4b852007b 100644 --- a/script/migrations/split-sibling-paragraphs-with-p-br.rb +++ b/script/migrations/split-sibling-paragraphs-with-p-br.rb @@ -73,7 +73,7 @@ class SeparateSiblingParagraphs next unless fragment fragment.find_all("p + p").each do |node| - unless ineligible?(node) || ineligible?(node.previous_sibling) + unless empty_node?(node) || empty_node?(node.previous_sibling) node.add_previous_sibling empty_node_markup edited = true insertions += 1 @@ -104,10 +104,6 @@ class SeparateSiblingParagraphs { 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 @@ -116,10 +112,6 @@ class SeparateSiblingParagraphs "


    " end - def wrapping_attachment?(node) - node.children.first&.name == "action-text-attachment" - end - def demo_card?(record) record.is_a?(Card) && record.number <= 8 end From 6b427ad9b9d68fb161a758d780dedf4fdfd9fa6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 18 Dec 2025 17:34:47 +0000 Subject: [PATCH 212/274] Update timestamp --- script/migrations/split-sibling-paragraphs-with-p-br.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/migrations/split-sibling-paragraphs-with-p-br.rb b/script/migrations/split-sibling-paragraphs-with-p-br.rb index 4b852007b..d0844a540 100644 --- a/script/migrations/split-sibling-paragraphs-with-p-br.rb +++ b/script/migrations/split-sibling-paragraphs-with-p-br.rb @@ -1,6 +1,6 @@ #!/usr/bin/env ruby -BACKFILL_TIMESTAMP = Time.parse("2025-12-18 00:00:00 UTC") +BACKFILL_TIMESTAMP = Time.parse("2025-12-19 00:07:00 UTC") ACCOUNT_ID = nil # restrict to an account_id # Split sibling

    tags with content by inserting

    to replicaate previous view. From e67e658e98a9fe94cb0eda3e505df6479590059b Mon Sep 17 00:00:00 2001 From: Oleh Khomei Date: Thu, 18 Dec 2025 20:37:28 +0200 Subject: [PATCH 213/274] Remove unused method --- app/helpers/application_helper.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b3657c76e..946a0900e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -10,12 +10,6 @@ module ApplicationHelper tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options end - def inline_svg(name) - file_path = "#{Rails.root}/app/assets/images/#{name}.svg" - return File.read(file_path).html_safe if File.exist?(file_path) - "(not found)" - end - def back_link_to(label, url, action, **options) link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe From dddaf861d44f3c695fff5c20e645d7eae530bc7a Mon Sep 17 00:00:00 2001 From: Robin Brandt Date: Thu, 18 Dec 2025 13:56:24 -0500 Subject: [PATCH 214/274] Bump mittens gem to support build on FreeBSD (#2196) --- Gemfile.lock | 2 +- Gemfile.saas.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 57315b541..7960ef6de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -267,7 +267,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.0) + mittens (0.3.1) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 576bbc731..ec5266035 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -346,7 +346,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.0) + mittens (0.3.1) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) From fa2b2fb194be62382c00c01209b36145ad25e9be Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 18 Dec 2025 16:47:36 -0600 Subject: [PATCH 215/274] Hotkey `m` to toggle assignment to self --- app/views/cards/assignments/new.html.erb | 1 + app/views/cards/display/common/_assignees.html.erb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index 048bacf62..609478550 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -19,6 +19,7 @@

    diff --git a/app/views/layouts/shared/_welcome_letter.html.erb b/app/views/layouts/shared/_welcome_letter.html.erb new file mode 100644 index 000000000..a0bb7c911 --- /dev/null +++ b/app/views/layouts/shared/_welcome_letter.html.erb @@ -0,0 +1,23 @@ +
    + +
    + <%= image_tag "jf-avatar.jpg", size: 36%> +
    + +

    Thanks for signing up — your account is ready to go!

    + +

    Fizzy is modern spin on kanban for tracking just about anything. Bugs, issues, ideas, small projects, it fits them all.

    + +

    Fast, straightforward, and vibrant, it’s a blast of fresh air in a shockingly stale category.

    + +

    We invite you to poke around and use Fizzy to track 1000 things for free. I’m glad you’re here, and I’d love to hear what you think.

    + +

    Thanks again and all the best,

    + + + +

    Jason Fried, jason@37signals.com
    + Co-founder of 37signals, makers of Fizzy +

    +
    +
    From 10f27f19a65ada400f0edfb86a743b9509e388b1 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 19 Dec 2025 15:46:18 -0600 Subject: [PATCH 262/274] Add a close button --- app/assets/stylesheets/welcome-letter.css | 6 ++++++ app/views/layouts/shared/_welcome_letter.html.erb | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/welcome-letter.css b/app/assets/stylesheets/welcome-letter.css index 0a979782b..bc98ea93b 100644 --- a/app/assets/stylesheets/welcome-letter.css +++ b/app/assets/stylesheets/welcome-letter.css @@ -1,8 +1,14 @@ @layer components { .welcome-letter { + position: relative; z-index: 99; } + .welcome-letter__close { + inset: var(--block-space) var(--block-space) auto auto; + position: absolute; + } + .welcome-letter__signature { background-color: currentColor; block-size: 3em; diff --git a/app/views/layouts/shared/_welcome_letter.html.erb b/app/views/layouts/shared/_welcome_letter.html.erb index a0bb7c911..1d58f9558 100644 --- a/app/views/layouts/shared/_welcome_letter.html.erb +++ b/app/views/layouts/shared/_welcome_letter.html.erb @@ -1,5 +1,10 @@ -
    +
    + +
    <%= image_tag "jf-avatar.jpg", size: 36%>
    From 69f6149ddcea402b27f420d8b6f91d18cccfc60d Mon Sep 17 00:00:00 2001 From: Jason Fried Date: Fri, 19 Dec 2025 15:29:20 -0800 Subject: [PATCH 263/274] Updated the welcome message. --- app/views/layouts/shared/_welcome_letter.html.erb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/layouts/shared/_welcome_letter.html.erb b/app/views/layouts/shared/_welcome_letter.html.erb index 1d58f9558..82f10e541 100644 --- a/app/views/layouts/shared/_welcome_letter.html.erb +++ b/app/views/layouts/shared/_welcome_letter.html.erb @@ -9,20 +9,20 @@ <%= image_tag "jf-avatar.jpg", size: 36%>
    -

    Thanks for signing up — your account is ready to go!

    +

    Welcome, and thanks for signing up for Fizzy.

    -

    Fizzy is modern spin on kanban for tracking just about anything. Bugs, issues, ideas, small projects, it fits them all.

    +

    -

    Fast, straightforward, and vibrant, it’s a blast of fresh air in a shockingly stale category.

    +

    To get you started, we set you up with a Fizzy board called Playground. It’s got a few cards designed to help you learn Fizzy itself. Open each card, go through the simple steps, and you’ll be an expert in Fizzy in no time. You’ll see the Playground when you close this message.

    -

    We invite you to poke around and use Fizzy to track 1000 things for free. I’m glad you’re here, and I’d love to hear what you think.

    +

    If you ever need a hand, please contact me directly at jason@37signals.com. I'm here for you, we’re all here for you.

    Thanks again and all the best,

    Jason Fried, jason@37signals.com
    - Co-founder of 37signals, makers of Fizzy + CEO & co-founder of 37signals, makers of Fizzy, Basecamp, and HEY

    From f2b49d0798a5f5986023a5b02221e61f099139c1 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Sat, 20 Dec 2025 17:34:05 -0600 Subject: [PATCH 264/274] Avoid orphans --- app/assets/stylesheets/welcome-letter.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/welcome-letter.css b/app/assets/stylesheets/welcome-letter.css index bc98ea93b..5afdb9dd0 100644 --- a/app/assets/stylesheets/welcome-letter.css +++ b/app/assets/stylesheets/welcome-letter.css @@ -2,6 +2,10 @@ .welcome-letter { position: relative; z-index: 99; + + h2, p { + text-wrap: pretty; + } } .welcome-letter__close { From 3b1c67f57d10a0bbcbd8f61b7d66f39e7123fe55 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Sat, 20 Dec 2025 17:34:19 -0600 Subject: [PATCH 265/274] Undo debug --- app/views/layouts/application.html.erb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 56b21c17a..b8cd2c70d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -27,8 +27,7 @@
    <% end %> - <%= render "layouts/shared/welcome_letter" if true %> - <%#= render "layouts/shared/welcome_letter" if flash[:welcome_letter] %> + <%= render "layouts/shared/welcome_letter" if flash[:welcome_letter] %>
    From c32286da002e69f3ccb072e226d173716b60aff4 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Sat, 20 Dec 2025 17:46:31 -0600 Subject: [PATCH 266/274] Remove second data-action attribute --- app/views/layouts/shared/_welcome_letter.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/shared/_welcome_letter.html.erb b/app/views/layouts/shared/_welcome_letter.html.erb index 82f10e541..4d7b5c277 100644 --- a/app/views/layouts/shared/_welcome_letter.html.erb +++ b/app/views/layouts/shared/_welcome_letter.html.erb @@ -1,4 +1,4 @@ -
    +