From db57a7ba0f45e8c27c2761c9af010cce2f808905 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Sun, 23 Nov 2025 06:04:20 +0100 Subject: [PATCH] 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