Merge pull request #1558 from basecamp/plan-b

Fizzy on MySQL
This commit is contained in:
Kevin McConnell
2025-11-18 18:53:19 +00:00
committed by GitHub
550 changed files with 5495 additions and 7693 deletions
+7 -1
View File
@@ -1,6 +1,12 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY)
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)
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)
+7 -1
View File
@@ -1,6 +1,12 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY)
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)
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)
+4
View File
@@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
AllCops:
Exclude:
- 'db/migrate/**/*'
-2
View File
@@ -1,5 +1,3 @@
# syntax = docker/dockerfile:1
# 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
+7 -6
View File
@@ -10,7 +10,6 @@ gem "stimulus-rails"
gem "turbo-rails"
# Deployment and drivers
gem "activerecord-tenanted", bc: "activerecord-tenanted"
gem "bootsnap", require: false
gem "kamal", require: false
gem "puma", ">= 5.0"
@@ -19,7 +18,7 @@ gem "solid_cache", "~> 1.0"
gem "solid_queue", "~> 1.2"
gem "sqlite3", ">= 2.0"
gem "thruster", require: false
gem "beamer-rails", "~> 0.1.0.beta4"
gem "trilogy", "~> 2.9"
# Features
gem "bcrypt", "~> 3.1.7"
@@ -34,6 +33,7 @@ gem "platform_agent"
gem "aws-sdk-s3", require: false
gem "web-push"
gem "net-http-persistent"
gem "mittens"
# Telemetry, logging, and operations
gem "mission_control-jobs"
@@ -54,12 +54,13 @@ gem "autotuner"
gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings
group :development, :test do
gem "debug"
gem "bundler-audit", require: false
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
gem "letter_opener"
gem "bundler-audit", require: false
gem "debug"
gem "faker"
gem "letter_opener"
gem "rack-mini-profiler"
gem "rubocop-rails-omakase", require: false
end
group :development do
+27 -41
View File
@@ -1,12 +1,3 @@
GIT
remote: https://github.com/basecamp/activerecord-tenanted
revision: 1e92cb34d0b7b02875040826330c1918abb06e9d
specs:
activerecord-tenanted (0.6.0)
activerecord (>= 8.1.beta)
railties (>= 8.1.beta)
zeitwerk
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: eb01c697de1ad028afc65cc7d9b5345a7a8e849f
@@ -36,7 +27,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: bc41ad696a76a151db56259938dccf64ef87b501
revision: 077c3ad60db4c43cccb8f4637b53b8d8eb7a3c19
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -161,10 +152,10 @@ GEM
anyway_config (2.7.2)
ruby-next-core (~> 1.0)
ast (2.4.3)
autotuner (1.1.0)
autotuner (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1181.0)
aws-sdk-core (3.236.0)
aws-partitions (1.1175.0)
aws-sdk-core (3.234.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -172,10 +163,10 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.117.0)
aws-sdk-kms (1.115.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.203.0)
aws-sdk-s3 (1.201.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -184,22 +175,12 @@ GEM
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
beamer-rails (0.1.0.beta4-arm64-darwin)
sqlite3 (>= 2.0)
beamer-rails (0.1.0.beta4-arm64-linux-gnu)
sqlite3 (>= 2.0)
beamer-rails (0.1.0.beta4-x86_64-darwin)
sqlite3 (>= 2.0)
beamer-rails (0.1.0.beta4-x86_64-linux-gnu)
sqlite3 (>= 2.0)
beamer-rails (0.1.0.beta4-x86_64-linux-musl)
sqlite3 (>= 2.0)
benchmark (0.5.0)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.1.1)
brakeman (7.1.0)
racc
builder (3.3.0)
bundler-audit (0.9.2)
@@ -231,7 +212,7 @@ GEM
drb (2.2.3)
dry-initializer (3.2.0)
ed25519 (1.4.0)
erb (5.1.3)
erb (6.0.0)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -320,6 +301,7 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
mittens (0.3.0)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
@@ -402,6 +384,8 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack-mini-profiler (4.0.1)
rack (>= 1.2.0)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -477,10 +461,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.1.0)
sentry-rails (6.0.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.1.0)
sentry-ruby (6.1.0)
sentry-ruby (~> 6.0.0)
sentry-ruby (6.0.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sniffer (0.5.0)
@@ -491,23 +475,23 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.10)
solid_cache (1.0.8)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.2.4)
solid_queue (1.2.2)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.8.0)
sqlite3 (2.7.4)
mini_portile2 (~> 2.8.0)
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sqlite3 (2.7.4-arm64-darwin)
sqlite3 (2.7.4-x86_64-darwin)
sqlite3 (2.7.4-x86_64-linux-gnu)
sqlite3 (2.7.4-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -517,13 +501,14 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
stringio (3.1.8)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-darwin)
thruster (0.1.16-x86_64-linux)
timeout (0.4.4)
trilogy (2.9.0)
tsort (0.2.0)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
@@ -545,7 +530,7 @@ GEM
web-push (3.0.2)
jwt (~> 3.0)
openssl (~> 3.0)
webmock (3.26.1)
webmock (3.26.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -595,12 +580,10 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
activerecord-tenanted!
activeresource
autotuner
aws-sdk-s3
bcrypt (~> 3.1.7)
beamer-rails (~> 0.1.0.beta4)
benchmark
bootsnap
brakeman
@@ -617,6 +600,7 @@ DEPENDENCIES
letter_opener
lexxy
mission_control-jobs
mittens
mocha
net-http-persistent
platform_agent
@@ -624,6 +608,7 @@ DEPENDENCIES
propshaft
puma (>= 5.0)
queenbee!
rack-mini-profiler
rails!
rails_structured_logging!
redcarpet
@@ -639,6 +624,7 @@ DEPENDENCIES
sqlite3 (>= 2.0)
stimulus-rails
thruster
trilogy (~> 2.9)
turbo-rails
vcr
web-console
-3
View File
@@ -1,3 +0,0 @@
web: bin/thrust bin/rails server
beamer: bin/beamer --debug --directory ./storage/ run --primary ${BEAMER_PRIMARY:-$(hostname)}
migrations: bin/beamer-testbed run-migrations --debug --directory ./storage/
+1 -1
View File
@@ -133,7 +133,7 @@
background-color: var(--color-container);
border-radius: 0.2em;
grid-area: card;
padding: clamp(2rem, 4vw, var(--padding-block)) clamp(16px, 4vw, var(--padding-inline));
padding: clamp(2rem, 4vw, var(--padding-block));
}
.card-perma__actions {
+25 -3
View File
@@ -82,13 +82,35 @@
.header__logo {
color: var(--color-ink);
font-size: 1.2rem;
inline-size: auto;
margin-block-start: 0.1em;
img {
span {
background: var(--color-ink-lightest);
block-size: auto;
inline-size: 1.15em;
margin-inline-end: 0.8ch;
border-radius: 0.3125em;
box-shadow:
0 0 0 1px oklch(var(--lch-ink-darkest) / 0.1),
0 0.1em 0.2em -0.1em oklch(var(--lch-ink-darkest) / 0.05),
0 0.2em 0.4em -0.2em oklch(var(--lch-ink-darkest) / 0.05),
0 0.3em 0.6em -0.3em oklch(var(--lch-ink-darkest) / 0.05)
;
display: grid;
height: 1.5em;
inline-size: 1.5em;
padding: 0.325em 0.275em 0.225em 0.275em;
place-content: center;
width: 1.5em;
}
svg {
height: 100%;
margin-inline-start: 0.4125em;
margin-inline-end: 0.5375em;
max-height: 0.8625em;
overflow: visible;
width: auto;
}
}
+4
View File
@@ -48,6 +48,10 @@
margin: 0;
}
}
@supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) {
font-size: max(16px, 1em) !important;
}
}
.input--file {
+9 -1
View File
@@ -42,11 +42,19 @@
display: grid;
height: 1.5em;
inline-size: 1.5em;
margin-inline-end: 0.8ch;
padding: 0.325em 0.275em 0.225em 0.275em;
place-content: center;
width: 1.5em;
}
svg {
height: 100%;
margin-inline-start: 0.4125em;
margin-inline-end: 0.5375em;
max-height: 0.8625em;
overflow: visible;
width: auto;
}
}
/* Dialog
+2
View File
@@ -49,6 +49,8 @@
.contain { contain: inline-size; }
.display-inline { display: inline; }
.flex { display: flex; }
.flex-inline { display: inline-flex; }
.flex-column { flex-direction: column; }
+3 -3
View File
@@ -3,15 +3,15 @@ module ApplicationCable
identified_by :current_user
def connect
super
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = find_session_by_cookie
membership = session.identity.memberships.find_by!(tenant: current_tenant)
self.current_user = membership.user if membership.user.active?
account = Account.find_by(external_account_id: request.env["fizzy.external_account_id"])
Current.account = account
self.current_user = session.identity.users.find_by!(account: account) if account
end
end
@@ -2,7 +2,7 @@ class Account::EntropiesController < ApplicationController
before_action :ensure_admin
def update
Account.sole.entropy.update!(entropy_params)
Current.account.entropy.update!(entropy_params)
redirect_to account_settings_path, notice: "Account updated"
end
@@ -20,7 +20,7 @@ class Account::JoinCodesController < ApplicationController
private
def set_join_code
@join_code = Account::JoinCode.sole
@join_code = Current.account.join_code
end
def join_code_params
@@ -3,7 +3,7 @@ class Account::SettingsController < ApplicationController
before_action :set_account
def show
@users = User.active.alphabetically
@users = @account.users.active.alphabetically
end
def update
@@ -13,7 +13,7 @@ class Account::SettingsController < ApplicationController
private
def set_account
@account = Account.sole
@account = Current.account
end
def account_params
+2 -3
View File
@@ -1,13 +1,12 @@
class ApplicationController < ActionController::Base
include LoadBalancerRouting, WriterAffinity
include Authentication
include Authorization
include CurrentRequest, CurrentTimezone, SetPlatform
include TurboFlash, ViewTransitions
include Saas
include RoutingHeaders, WriterAffinity
etag { "v1" }
stale_when_importmap_changes
allow_browser versions: :modern
etag { "v1" } # 2025-11-05 @todo: To invalidate HTTP cache after big renaming. To remove after a few days.
end
@@ -2,7 +2,7 @@ class Boards::Columns::ClosedsController < ApplicationController
include BoardScoped
def show
set_page_and_extract_portion_from @board.cards.closed.recently_closed_first
set_page_and_extract_portion_from @board.cards.closed.recently_closed_first.preloaded
fresh_when etag: @page.records
end
end
@@ -2,7 +2,7 @@ class Boards::Columns::NotNowsController < ApplicationController
include BoardScoped
def show
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first.preloaded
fresh_when etag: @page.records
end
end
@@ -2,7 +2,7 @@ class Boards::Columns::StreamsController < ApplicationController
include BoardScoped
def show
set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first
set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded
fresh_when etag: @page.records
end
end
+1 -1
View File
@@ -4,7 +4,7 @@ class Boards::ColumnsController < ApplicationController
before_action :set_column, only: %i[ show update destroy ]
def show
set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first
set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded
fresh_when etag: @page.records
end
+4 -3
View File
@@ -24,7 +24,7 @@ class BoardsController < ApplicationController
def edit
selected_user_ids = @board.users.pluck :id
@selected_users, @unselected_users = \
User.active.alphabetically.partition { |user| selected_user_ids.include? user.id }
@board.account.users.active.alphabetically.partition { |user| selected_user_ids.include? user.id }
end
def update
@@ -64,7 +64,8 @@ class BoardsController < ApplicationController
end
def show_columns
set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first
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 ]
end
@@ -73,7 +74,7 @@ class BoardsController < ApplicationController
end
def grantees
User.active.where id: grantee_ids
@board.account.users.active.where id: grantee_ids
end
def revokees
+1 -1
View File
@@ -16,6 +16,6 @@ class Cards::BoardsController < ApplicationController
private
def set_card
@card = Current.user.accessible_cards.find(params[:card_id])
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class Cards::ColumnsController < ApplicationController
def edit
@card = Current.user.accessible_cards.find(params[:card_id])
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
@columns = @card.board.columns.sorted
fresh_when etag: [ @card, @columns ]
+1 -1
View File
@@ -2,7 +2,7 @@ class Cards::TaggingsController < ApplicationController
include CardScoped
def new
@tags = Tag.all.alphabetically
@tags = Current.account.tags.all.alphabetically
fresh_when etag: [ @tags, @card.tags ]
end
+2 -13
View File
@@ -21,10 +21,7 @@ class CardsController < ApplicationController
end
def update
suppressing_broadcasts_unless_published(@card) do
@card.update! card_params
end
@card.update! card_params
redirect_to @card
end
@@ -39,21 +36,13 @@ class CardsController < ApplicationController
end
def set_card
@card = Current.user.accessible_cards.find params[:id]
@card = Current.user.accessible_cards.find_by!(number: params[:id])
end
def ensure_permission_to_administer_card
head :forbidden unless Current.user.can_administer_card?(@card)
end
def suppressing_broadcasts_unless_published(card, &block)
if card.published?
yield
else
Board.suppressing_turbo_broadcasts(&block)
end
end
def card_params
params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ])
end
+7 -17
View File
@@ -2,10 +2,7 @@ module Authentication
extend ActiveSupport::Concern
included do
# Checking for tenant must happen first so we redirect before trying to access the db.
before_action :require_tenant
prepend_before_action :clear_old_scoped_session_cookies
before_action :require_account # Checking and setting account must happen first
before_action :require_authentication
helper_method :authenticated?
@@ -26,8 +23,8 @@ module Authentication
allow_unauthorized_access **options
end
def require_untenanted_access(**options)
skip_before_action :require_tenant, **options
def disallow_account_scope(**options)
skip_before_action :require_account, **options
before_action :redirect_tenanted_request, **options
end
end
@@ -37,8 +34,8 @@ module Authentication
Current.session.present?
end
def require_tenant
if ApplicationRecord.current_tenant.blank?
def require_account
unless Current.account.present?
redirect_to session_menu_url(script_name: nil)
end
end
@@ -53,19 +50,12 @@ module Authentication
end
end
# FIXME: Remove before launch
def clear_old_scoped_session_cookies
if request.script_name.present? && cookies.signed[:session_token].present? && !find_session_by_cookie
cookies.signed[:session_token] = { value: "invalid-session-token", path: request.script_name, expires: 1.hour.ago }
end
end
def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end
def request_authentication
if ApplicationRecord.current_tenant.present?
if Current.account.present?
session[:return_to_after_authenticating] = request.url
end
@@ -81,7 +71,7 @@ module Authentication
end
def redirect_tenanted_request
redirect_to root_url if ApplicationRecord.current_tenant
redirect_to root_url if Current.account.present?
end
def start_new_session_for(identity)
+2 -8
View File
@@ -2,7 +2,7 @@ module Authorization
extend ActiveSupport::Concern
included do
before_action :ensure_can_access_account, if: -> { ApplicationRecord.current_tenant && authenticated? }
before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? }
end
class_methods do
@@ -26,13 +26,7 @@ module Authorization
end
def ensure_can_access_account
if Current.membership.blank?
redirect_to session_menu_url(script_name: nil)
elsif Current.user.nil? && Current.membership.join_code.present?
redirect_to new_users_join_path
elsif !Current.user&.active?
redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking))
end
redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active?
end
def redirect_existing_user
+1 -1
View File
@@ -7,7 +7,7 @@ module CardScoped
private
def set_card
@card = Current.user.accessible_cards.find(params[:card_id])
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
def set_board
+1 -1
View File
@@ -7,6 +7,6 @@ module ColumnScoped
private
def set_column
@column = Column.find(params[:column_id])
@column = Current.account.columns.find(params[:column_id])
end
end
@@ -1,39 +0,0 @@
module LoadBalancerRouting
extend ActiveSupport::Concern
ALLOWED_PRAGMAS = %w[ beamer_primary beamer_last_txn ]
included do
before_action :set_target_header, :set_writer_header
after_action :set_transaction_cookie
end
private
def set_target_header
response.headers["X-Kamal-Target"] = request.headers["X-Kamal-Target"]
end
def set_writer_header
if ApplicationRecord.current_tenant.present?
response.headers["X-Writer"] = pragma("beamer_primary")
end
end
def set_transaction_cookie
unless safe_request?
if ApplicationRecord.current_tenant.present? && Account.sole.present?
cookies[:last_transaction] = { value: pragma("beamer_last_txn"), path: Account.sole.slug }
end
end
end
def pragma(name)
if ALLOWED_PRAGMAS.include?(name)
ApplicationRecord.connection.execute("pragma #{name}").first&.values&.first
end
end
def safe_request?
request.get? || request.head?
end
end
@@ -0,0 +1,12 @@
module RoutingHeaders
extend ActiveSupport::Concern
included do
before_action :set_target_header
end
private
def set_target_header
response.headers["X-Kamal-Target"] = request.headers["X-Kamal-Target"]
end
end
+17 -22
View File
@@ -1,43 +1,38 @@
class JoinCodesController < ApplicationController
require_untenanted_access
allow_unauthenticated_access
before_action :set_join_code
before_action :ensure_join_code_is_valid
layout "public"
def new
@account_name = ApplicationRecord.with_tenant(tenant) { Account.sole.name }
end
def create
Identity.transaction do
identity = Identity.find_or_create_by!(email_address: params.expect(:email_address))
identity.memberships.find_or_create_by!(tenant: tenant) do |membership|
membership.join_code = code
end
identity = Identity.find_or_create_by!(email_address: params.expect(:email_address))
@join_code.redeem { |account| identity.join(account) } unless identity.member_of?(@join_code.account)
if identity == Current.identity
redirect_to landing_url(script_name: @join_code.account.slug)
else
terminate_session if Current.identity
magic_link = identity.send_magic_link
flash[:magic_link_code] = magic_link&.code if Rails.env.development?
end
session[:return_to_after_authenticating] = landing_url(script_name: "/#{tenant}")
redirect_to session_magic_link_path
session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug)
redirect_to session_magic_link_url(script_name: nil)
end
end
private
def set_join_code
@join_code ||= Account::JoinCode.active.find_by(code: params.expect(:code), account: Current.account)
end
def ensure_join_code_is_valid
head :not_found unless @join_code&.active?
end
def set_join_code
@join_code ||= ApplicationRecord.with_tenant(tenant) { Account::JoinCode.active.find_by(code: code) }
end
def tenant
params.expect(:tenant)
end
def code
params.expect(:code)
end
end
@@ -1,28 +0,0 @@
class Memberships::EmailAddresses::ConfirmationsController < ApplicationController
require_untenanted_access
allow_unauthenticated_access
before_action :set_membership
rate_limit to: 5, within: 1.hour, only: :create
def show
end
def create
membership = Membership.change_email_address_using_token(token)
terminate_session if Current.session
start_new_session_for membership.reload.identity
redirect_to edit_user_url(script_name: "/#{@membership.tenant}", id: @membership.user)
end
private
def set_membership
@membership = Membership.find(params[:membership_id])
end
def token
params.expect :email_address_token
end
end
@@ -1,31 +0,0 @@
class Memberships::EmailAddressesController < ApplicationController
require_untenanted_access
layout "public"
before_action :set_membership
rate_limit to: 5, within: 1.hour, only: :create
def new
end
def create
identity = Identity.find_by_email_address(new_email_address)
if identity&.memberships&.exists?(tenant: @membership.tenant)
flash[:alert] = "You already have a user in this account with that email address"
redirect_to new_email_address_path
else
@membership.send_email_address_change_confirmation(new_email_address)
end
end
private
def set_membership
@membership = Current.identity.memberships.find(params[:membership_id])
end
def new_email_address
params.expect :email_address
end
end
@@ -1,17 +0,0 @@
class Memberships::UnlinkController < ApplicationController
require_untenanted_access
before_action :set_membership
def show
end
def create
@membership.destroy
redirect_to session_menu_path
end
private
def set_membership
@membership = Current.identity.memberships.find_signed!(params[:membership_id], purpose: :unlinking)
end
end
+2 -2
View File
@@ -2,8 +2,8 @@ class My::MenusController < ApplicationController
def show
@filters = Current.user.filters.all
@boards = Current.user.boards.ordered_by_recently_accessed
@tags = Tag.all.alphabetically
@users = User.active.alphabetically
@tags = Current.account.tags.all.alphabetically
@users = Current.account.users.active.alphabetically
fresh_when etag: [ @filters, @boards, @tags, @users ]
end
+2 -2
View File
@@ -1,7 +1,7 @@
class NotificationsController < ApplicationController
def index
@unread = Current.user.notifications.unread.ordered unless current_page_param
set_page_and_extract_portion_from Current.user.notifications.read.ordered
@unread = Current.user.notifications.unread.ordered.preloaded unless current_page_param
set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded
respond_to do |format|
format.turbo_stream if current_page_param # Allows read-all action to side step pagination
+2 -2
View File
@@ -20,7 +20,7 @@ class Prompts::CardsController < ApplicationController
def search_cards
published_cards
.mentioning(params[:filter])
.mentioning(params[:filter], user: Current.user)
.reverse_chronologically
.limit(MAX_RESULTS)
end
@@ -30,7 +30,7 @@ class Prompts::CardsController < ApplicationController
end
def prepending_exact_matches_by_id(cards)
if card_by_id = Current.user.accessible_cards.find_by_id(params[:filter])
if card_by_id = Current.user.accessible_cards.find_by(number: params[:filter])
[ card_by_id ] + cards
else
cards
+1 -1
View File
@@ -1,6 +1,6 @@
class Prompts::TagsController < ApplicationController
def index
@tags = Tag.all.alphabetically
@tags = Current.account.tags.all.alphabetically
if stale? etag: @tags
render layout: false
+1 -1
View File
@@ -1,6 +1,6 @@
class Prompts::UsersController < ApplicationController
def index
@users = User.active.alphabetically
@users = Current.account.users.active.alphabetically
if stale? etag: @users
render layout: false
+1 -1
View File
@@ -11,7 +11,7 @@ class Public::BaseController < ApplicationController
end
def set_card
@card = @board.cards.find(params[:id]) if params[:board_id] && params[:id]
@card = @board.cards.find_by!(number: params[:id]) if params[:board_id] && params[:id]
end
def set_public_cache_expiration
+1 -1
View File
@@ -1,5 +1,5 @@
class PwaController < ApplicationController
require_untenanted_access
disallow_account_scope
skip_forgery_protection
# We need a stable URL at the root, so we can't use the regular asset path here.
+6 -1
View File
@@ -3,6 +3,11 @@ class QrCodesController < ApplicationController
def show
expires_in 1.year, public: true
render svg: RQRCode::QRCode.new(QrCodeLink.from_signed(params[:id]).url).as_svg(viewbox: true, fill: :white, color: :black)
qr_code_svg = RQRCode::QRCode
.new(QrCodeLink.from_signed(params[:id]).url)
.as_svg(viewbox: true, fill: :white, color: :black)
render svg: qr_code_svg
end
end
+1 -6
View File
@@ -5,13 +5,8 @@ class SearchesController < ApplicationController
if card = Current.user.accessible_cards.find_by_id(params[:q])
@card = card
else
perform_search
end
end
private
def perform_search
set_page_and_extract_portion_from Current.user.search(params[:q])
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
end
end
end
@@ -1,5 +1,5 @@
class Sessions::MagicLinksController < ApplicationController
require_untenanted_access
disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." }
+4 -15
View File
@@ -1,24 +1,13 @@
class Sessions::MenusController < ApplicationController
require_untenanted_access
before_action(if: :render_as_menu_section?) { request.variant = :menu_section }
disallow_account_scope
layout "public"
def show
@memberships = Current.identity.memberships
@accounts = Current.identity.accounts
if params[:without]
@memberships = @memberships.where.not(tenant: params[:without])
end
if @memberships.one? && !render_as_menu_section?
redirect_to root_url(script_name: "/#{@memberships.first.tenant}")
if @accounts.one?
redirect_to root_url(script_name: @accounts.first.slug)
end
end
private
def render_as_menu_section?
params[:menu_section]
end
end
@@ -1,5 +1,5 @@
class Sessions::TransfersController < ApplicationController
require_untenanted_access
disallow_account_scope
require_unauthenticated_access
def show
+9 -5
View File
@@ -1,10 +1,14 @@
class SessionsController < ApplicationController
# FIXME: Remove this before launch!
SIGNUP_USERNAME = Rails.env.local? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name
SIGNUP_PASSWORD = Rails.env.local? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
http_basic_authenticate_with name: SIGNUP_USERNAME, password: SIGNUP_PASSWORD, realm: "Fizzy Signup", only: :create, unless: -> { Identity.exists?(email_address: email_address) }
if Rails.env.remote?
http_basic_authenticate_with \
name: Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.application.credentials.account_signup_http_basic_auth.password,
realm: "Fizzy Signup",
only: :create, unless: -> { Identity.exists?(email_address: email_address) }
end
require_untenanted_access
disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
@@ -20,7 +24,7 @@ class SessionsController < ApplicationController
redirect_to session_magic_link_path
elsif signups_allowed?
Signup.new(email_address: email_address).create_identity
session[:return_to_after_authenticating] = saas.new_signup_membership_path
session[:return_to_after_authenticating] = saas.new_signup_completion_path
redirect_to session_magic_link_path
end
end
+2 -2
View File
@@ -19,11 +19,11 @@ class Users::AvatarsController < ApplicationController
private
def set_user
@user = User.find(params[:user_id])
@user = Current.account.users.find(params[:user_id])
end
def ensure_permission_to_administer_user
head :forbidden unless Current.user.admin? || Current.user == @user
head :forbidden unless Current.user.can_change?(@user)
end
def render_avatar_or_initials
@@ -0,0 +1,27 @@
class Users::EmailAddresses::ConfirmationsController < ApplicationController
allow_unauthenticated_access
before_action :set_user
rate_limit to: 5, within: 1.hour, only: :create
def show
end
def create
user = @user.change_email_address_using_token(token)
terminate_session if Current.session
start_new_session_for user.identity
redirect_to edit_user_url(script_name: user.account.slug, id: user)
end
private
def set_user
@user = Current.account.users.active.find(params[:user_id])
end
def token
params.expect :email_address_token
end
end
@@ -0,0 +1,27 @@
class Users::EmailAddressesController < ApplicationController
before_action :set_user
rate_limit to: 5, within: 1.hour, only: :create
def new
end
def create
identity = Identity.find_by_email_address(new_email_address)
if identity&.users&.exists?(account: @user.account)
flash[:alert] = "You already have a user in this account with that email address"
redirect_to new_user_email_address_path(@user)
else
@user.send_email_address_change_confirmation(new_email_address)
end
end
private
def set_user
@user = Current.identity.users.find(params[:user_id])
end
def new_email_address
params.expect :email_address
end
end
+1 -1
View File
@@ -12,7 +12,7 @@ class Users::EventsController < ApplicationController
private
def set_user
@user = User.active.find(params[:user_id])
@user = Current.account.users.active.find(params[:user_id])
end
def day_param
+1 -18
View File
@@ -1,32 +1,15 @@
class Users::JoinsController < ApplicationController
require_access_without_a_user
before_action :set_join_code, :ensure_join_code_is_valid
layout "public"
def new
end
def create
@join_code.redeem do
User.create!(user_params.merge(membership: Current.membership))
end
Current.user.update!(user_params)
redirect_to landing_path
end
private
def set_join_code
@join_code = Account::JoinCode.active.find_by(code: Current.membership.join_code)
end
def ensure_join_code_is_valid
unless @join_code&.active?
redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking))
end
end
def user_params
params.expect(user: [ :name, :avatar ])
end
+1 -1
View File
@@ -9,7 +9,7 @@ class Users::RolesController < ApplicationController
private
def set_user
@user = User.active.find(params[:user_id])
@user = Current.account.users.active.find(params[:user_id])
end
def ensure_permission_to_administer_user
+4 -4
View File
@@ -1,11 +1,11 @@
class UsersController < ApplicationController
before_action :set_user, only: %i[ show edit update destroy ]
before_action :set_user
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
def edit
def show
end
def show
def edit
end
def update
@@ -20,7 +20,7 @@ class UsersController < ApplicationController
private
def set_user
@user = User.active.find(params[:id])
@user = Current.account.users.active.find(params[:id])
end
def ensure_permission_to_change_user
@@ -2,7 +2,7 @@ class Webhooks::ActivationsController < ApplicationController
before_action :ensure_admin
def create
webhook = Webhook.find(params[:webhook_id])
webhook = Current.account.webhooks.find(params[:webhook_id])
webhook.activate
redirect_to webhook
+1 -1
View File
@@ -27,7 +27,7 @@ module AccessesHelper
end
def board_watchers_list(board)
watchers = board.watchers
watchers = board.watchers.with_avatars
displayed_watchers = watchers.limit(MAX_DISPLAYED_WATCHERS)
overflow_count = watchers.count - MAX_DISPLAYED_WATCHERS
+2 -2
View File
@@ -1,7 +1,7 @@
module ApplicationHelper
def page_title_tag
account_name = if ApplicationRecord.current_tenant && Current.session&.identity&.memberships&.many?
Account.sole&.name
account_name = if Current.account && Current.session&.identity&.users&.many?
Current.account&.name
end
tag.title [ @page_title, account_name, "Fizzy" ].compact.join(" | ")
end
+1 -1
View File
@@ -38,6 +38,6 @@ module AvatarsHelper
end
def avatar_image_tag(user, **options)
image_tag user_avatar_url(user), aria: { hidden: "true" }, size: 48, title: user.name, **options
image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options
end
end
+1 -1
View File
@@ -41,7 +41,7 @@ module CardsHelper
end
def button_to_remove_card_image(card)
button_to(card_image_path(card), method: :delete, class: "btn", data: { controller: "tooltip" }) do
button_to(card_image_path(card), method: :delete, class: "btn", data: { controller: "tooltip", action: "dialog#close" }) do
icon_tag("trash") + tag.span("Remove background image", class: "for-screen-reader")
end
end
+1 -1
View File
@@ -9,7 +9,7 @@ module EventsHelper
"comment"
when "card_title_changed"
"rename"
when "card_board_changed"
when "card_board_changed", "card_triaged", "card_postponed"
"move"
else
"person"
+3 -1
View File
@@ -5,7 +5,9 @@ module HotkeysHelper
if key == "ctrl" && platform.mac?
""
elsif key == "enter"
platform.mac? ? "return" : "enter"
""
elsif key == "shift"
""
else
key
end.capitalize
+1 -1
View File
@@ -25,7 +25,7 @@ module My::MenuHelper
def my_menu_tag_item(the_tag)
my_menu_item("tag", tag) do
link_to(tag.span("#{the_tag.title} (#{the_tag.cards_count})", class: "overflow-ellipsis"), cards_path(tag_ids: [ the_tag ]), class: "popup__btn btn")
link_to(tag.span(the_tag.title, class: "overflow-ellipsis"), cards_path(tag_ids: [ the_tag ]), class: "popup__btn btn")
end
end
+1 -1
View File
@@ -86,6 +86,6 @@ module NotificationsHelper
end
def card_notification_title(card)
card.title.presence || "Card #{card.id}"
card.title.presence || "Card #{card.number}"
end
end
@@ -0,0 +1,21 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
reloadInterval: { type: Number, default: 10 * 60 } // 10 minutes
}
connect() {
this.freshSince = Date.now()
}
reload() {
const now = Date.now()
const reloadIntervalMs = this.reloadIntervalValue * 1000
if ((now - this.freshSince) >= reloadIntervalMs) {
this.freshSince = now
this.element.reload()
}
}
}
@@ -0,0 +1,19 @@
import { Controller } from "@hotwired/stimulus"
import { onNextEventLoopTick } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "input" ]
submit(event) {
onNextEventLoopTick(() => {
this.inputTarget.disabled = true
})
}
paste(event) {
onNextEventLoopTick(() => {
this.element.submit()
this.inputTarget.disabled = true
})
}
}
@@ -40,7 +40,7 @@ export default class extends Controller {
return this.noSelectionLabelValue
}
const labels = this.#selectedItems().map(item => item.dataset.multiSelectionComboboxLabel)
const labels = this.#selectedItems.map(item => item.dataset.multiSelectionComboboxLabel)
const sentence = toSentence(labels, {
two_words_connector: " or ",
last_word_connector: ", or "
@@ -55,13 +55,28 @@ export default class extends Controller {
if (isSelected) {
item.setAttribute(this.selectPropertyNameValue, "false")
} else {
if (this.isAnExclusiveSelectionItemInvolved(item)) {
this.#deselectAll()
}
item.setAttribute(this.selectPropertyNameValue, "true")
}
this.#updateHiddenFields()
if (item.dataset.multiSelectionFieldName) {
this.#renameHiddenFields(item.dataset.multiSelectionFieldName)
}
this.labelTarget.textContent = this.#selectedLabel
}
isAnExclusiveSelectionItemInvolved(item) {
return this.#isExclusiveSelection(item) || Array.from(this.#selectedItems).some((item) => this.#isExclusiveSelection(item))
}
#isExclusiveSelection(item) {
return item.dataset.multiSelectionExclusive === "true"
}
#updateHiddenFields() {
this.#clearHiddenFields()
this.#addHiddenFields()
@@ -73,22 +88,32 @@ export default class extends Controller {
})
}
#selectedItems() {
get #selectedItems() {
return this.itemTargets.filter(item =>
item.getAttribute(this.selectPropertyNameValue) === "true"
)
}
#selectedValues() {
return this.#selectedItems().map(item => item.dataset.multiSelectionComboboxValue)
return this.#selectedItems.map(item => item.dataset.multiSelectionComboboxValue)
}
#clearHiddenFields() {
this.element.querySelectorAll('input[type="hidden"]').forEach(field => {
this.#hiddenFields.forEach(field => {
field.remove()
})
}
#renameHiddenFields(fieldName) {
this.#hiddenFields.forEach(field => {
field.setAttribute("name", fieldName)
})
}
get #hiddenFields() {
return this.element.querySelectorAll("input[type='hidden']")
}
#addHiddenFields() {
this.#selectedValues().forEach(value => {
const [ field ] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children
@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = [ "deleteable", "reveal", "perform" ]
static targets = [ "button", "content" ]
static values = { reacterId: Number }
static values = { reacterId: String }
connect() {
if (this.#currentUserIsReacter) {
+1 -1
View File
@@ -3,7 +3,7 @@ class Current {
const currentUserId = this.#extractContentFromMetaTag("current-user-id")
if (currentUserId) {
return { id: parseInt(currentUserId) }
return { id: currentUserId }
}
}
@@ -1,7 +0,0 @@
class Card::AutoPostponeAllDueJob < ApplicationJob
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_postpone_all_due
end
end
end
+2 -4
View File
@@ -1,9 +1,7 @@
class DeleteUnusedTagsJob < ApplicationJob
def perform
ApplicationRecord.with_each_tenant do |tenant|
Tag.unused.find_each do |tag|
tag.destroy!
end
Tag.unused.find_each do |tag|
tag.destroy!
end
end
end
@@ -2,8 +2,6 @@ class Notification::Bundle::DeliverAllJob < ApplicationJob
queue_as :backend
def perform
ApplicationRecord.with_each_tenant do |tenant|
Notification::Bundle.deliver_all
end
Notification::Bundle.deliver_all
end
end
-155
View File
@@ -1,155 +0,0 @@
require "find"
#
# This job backs up all the tenant databases using the SQLite Backup API, which should allow the
# application to continue running against the database while it is backed up.
#
# ref: https://www.sqlite.org/c3ref/backup_finish.html
#
# It will keep N files around, like this:
#
# storage/tenants/development/12345678/db/
# ├─ main.sqlite3
# ├─ main.sqlite3.1
# ├─ main.sqlite3.2
# ├─ main.sqlite3.3
# ├─ main.sqlite3.4
# └─ main.sqlite3.5
#
# On some systems, notably in production, we have an NFS-mounted filesystem into which the
# application copies the backup files for disaster recovery. We copy into an environment- and
# tenant-specific directory. The file, when copied, will be renamed with the timestamp of the file
# creation time. For example:
#
# /backups/production/12345678/main.sqlite3.20251014194804
#
# It will also clean up old backups in the NFS-mounted filesystem following our data retention policy.
#
class SQLiteBackupsJob < ApplicationJob
DEFAULT_NUMBER_OF_BACKUPS = 5
DEFAULT_STEP_PAGES = 1024
DEFAULT_SWEEP_DIR = "/backups"
DEFAULT_SWEEP_RETENTION = 30.days # https://37signals.com/policies/privacy
def perform(keep: DEFAULT_NUMBER_OF_BACKUPS, step: DEFAULT_STEP_PAGES, sweep_dir: DEFAULT_SWEEP_DIR, sweep_retention: DEFAULT_SWEEP_RETENTION)
@failures = []
ApplicationRecord.with_each_tenant do |tenant|
perform_file_rollover(tenant, keep:)
perform_backup(tenant, step:)
perform_sweep(tenant, sweep_dir:)
enforce_retention(tenant, sweep_dir:, sweep_retention:)
end
if @failures.present?
raise "SQLiteBackupsJob: failed to backup tenants: #{@failures.join(", ")}"
end
end
private
def perform_file_rollover(tenant, keep:)
keep.downto(2) do |j|
fresher = backup_path(tenant, j - 1)
staler = backup_path(tenant, j)
if j == keep && File.exist?(staler)
FileUtils.rm(staler)
end
if File.exist?(fresher)
# TODO: It may be worth benchmarking whether backing up into the previous backup is faster
# than backing up into an empty file.
FileUtils.mv(fresher, staler)
end
end
end
def perform_backup(tenant, step:)
ApplicationRecord.with_connection do |conn|
current_adapter = conn.raw_connection
backup_db = backup_path(tenant, 1)
backup_adapter = SQLite3::Database.new(backup_db)
backup = SQLite3::Backup.new(backup_adapter, "main", current_adapter, "main")
pages = 0
elapsed = ActiveSupport::Benchmark.realtime(:float_millisecond) do
loop do
status = backup.step(step)
case status
when SQLite3::Constants::ErrorCode::DONE
break
when SQLite3::Constants::ErrorCode::OK
total = backup.pagecount
progress = total - backup.remaining
log(tenant, :debug) { "Wrote #{progress} of #{total} pages." }
when SQLite3::Constants::ErrorCode::BUSY, SQLite3::Constants::ErrorCode::LOCKED
log(tenant, :debug) { "Busy, retrying." }
else
log(tenant, :error) { "Failed with status #{status}." }
@failures << tenant
end
end
pages = backup.pagecount
backup.finish
end
log(tenant) { sprintf("Backup complete in %<elapsed>.1f ms. Wrote %{pages} pages to %{path}", path: backup_db.inspect, pages: pages, elapsed: elapsed) }
end
end
def perform_sweep(tenant, sweep_dir:)
unless File.directory?(sweep_dir) && File.writable?(sweep_dir)
log(tenant, :warn) { "Skipping sweep, #{sweep_dir.inspect} does not exist or is not writable." }
return
end
backup_file = backup_path(tenant, 1)
sweep_path = File.join(sweep_dir, Rails.env, tenant)
FileUtils.mkdir_p(sweep_path)
if File.exist?(backup_file)
timestamp = File.ctime(backup_file).utc.strftime("%Y%m%d%H%M%S")
swept_file = File.join(sweep_path, File.basename(db_path(tenant)) + ".#{timestamp}")
FileUtils.cp(backup_file, swept_file)
log(tenant) { "Swept backup to #{swept_file.inspect}." }
else
log(tenant, :warn) { "No backup file found at #{backup_file.inspect} to sweep." }
end
end
def enforce_retention(tenant, sweep_dir:, sweep_retention:)
unless File.directory?(sweep_dir) && File.writable?(sweep_dir)
log(tenant, :warn) { "Skipping retention enforcement, #{sweep_dir.inspect} does not exist or is not writable." }
return
end
sweep_path = File.join(sweep_dir, Rails.env, tenant)
if File.directory?(sweep_path)
cutoff_time = Time.now - sweep_retention
Find.find(sweep_path) do |path|
if File.file?(path) && File.ctime(path) < cutoff_time
FileUtils.rm(path)
log(tenant) { "Removed old swept backup #{path.inspect}." }
end
end
end
end
def backup_path(tenant, index)
db_path(tenant) + ".#{index}"
end
def db_path(tenant)
db_config.config_adapter.database_path
end
def db_config
ApplicationRecord.connection_pool.db_config
end
def log(tenant, level = :info, &block)
message = block.call
Rails.logger.send(level, "[tenant=#{tenant}] SQLiteBackupsJob: #{message}")
end
end
@@ -1,7 +0,0 @@
class Webhook::CleanupDeliveriesJob < ApplicationJob
def perform
ApplicationRecord.with_each_tenant do
Webhook::Delivery.cleanup
end
end
end
+2 -2
View File
@@ -7,8 +7,8 @@ class ApplicationMailer < ActionMailer::Base
private
def default_url_options
if ApplicationRecord.current_tenant
super.merge(script_name: Account.sole.slug)
if Current.account
super.merge(script_name: Current.account.slug)
else
super
end
-7
View File
@@ -1,7 +0,0 @@
class IdentityMailer < ApplicationMailer
def email_change_confirmation(email_address:, token:, membership:)
@token = token
@membership = membership
mail to: email_address, subject: "Confirm your new email address"
end
end
+7
View File
@@ -0,0 +1,7 @@
class UserMailer < ApplicationMailer
def email_change_confirmation(email_address:, token:, user:)
@token = token
@user = user
mail to: email_address, subject: "Confirm your new email address"
end
end
+1
View File
@@ -1,4 +1,5 @@
class Access < ApplicationRecord
belongs_to :account, default: -> { user.account }
belongs_to :board, touch: true
belongs_to :user, touch: true
+15 -13
View File
@@ -1,6 +1,14 @@
class Account < ApplicationRecord
include Entropic, Seedeable
has_one :join_code
has_many :users, dependent: :destroy
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
has_many :webhooks, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :columns, dependent: :destroy
has_many_attached :uploads
after_create :create_join_code
@@ -9,24 +17,18 @@ class Account < ApplicationRecord
class << self
def create_with_admin_user(account:, owner:)
create!(**account).tap do
User.system
User.create!(**owner.reverse_merge(role: "admin"))
create!(**account).tap do |account|
account.users.create!(role: :system, name: "System")
account.users.create!(**owner.reverse_merge(role: "admin"))
end
end
end
# To use the account as a generic card container. See +Entropy+.
def cards
Card.all
end
def slug
"/#{tenant}"
"/#{external_account_id}"
end
private
def create_join_code
Account::JoinCode.create!
end
def account
self
end
end
+1 -1
View File
@@ -5,6 +5,6 @@ module Account::Entropic
included do
has_one :entropy, as: :container, dependent: :destroy
after_create -> { create_entropy!(auto_postpone_period: DEFAULT_ENTROPY_PERIOD) }
after_create -> { create_entropy!(auto_postpone_period: DEFAULT_ENTROPY_PERIOD, account: self) }
end
end
+3 -1
View File
@@ -1,6 +1,8 @@
class Account::JoinCode < ApplicationRecord
CODE_LENGTH = 12
belongs_to :account
scope :active, -> { where("usage_count < usage_limit") }
before_create :generate_code, if: -> { code.blank? }
@@ -8,7 +10,7 @@ class Account::JoinCode < ApplicationRecord
def redeem
transaction do
increment!(:usage_count)
yield if block_given?
yield account if block_given?
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ module Account::Seedeable
extend ActiveSupport::Concern
def setup_customer_template
Account::Seeder.new(self, User.active.first).seed
Account::Seeder.new(self, users.active.where(role: :admin).first).seed
end
end
+4 -8
View File
@@ -7,7 +7,7 @@ class Account::Seeder
end
def seed
Current.set session: session do
Current.set(user: creator, account: account) do
populate
end
end
@@ -20,15 +20,11 @@ class Account::Seeder
end
private
def session
creator.identity.sessions.last
end
def populate
# ---------------
# Playground Board
# ---------------
playground = Board.create! name: "Playground", creator: creator, all_access: true
playground = account.boards.create! name: "Playground", creator: creator, all_access: true
# Cards
playground.cards.create! creator: creator, title: "Finally, watch this Fizzy orientation video", status: "published", description: <<~HTML
@@ -104,8 +100,8 @@ class Account::Seeder
end
def delete_everything
Current.set session: session do
Board.destroy_all
Current.set(user: creator, account: account) do
account.boards.destroy_all
end
end
end
-5
View File
@@ -1,5 +0,0 @@
module Ai
def self.table_name_prefix
"ai_"
end
end
+4 -1
View File
@@ -1,4 +1,7 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
tenanted
connects_to database: { writing: :primary, reading: :replica }
attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
end
+1
View File
@@ -1,4 +1,5 @@
class Assignment < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :assignee, class_name: "User"
+1
View File
@@ -2,6 +2,7 @@ class Board < ApplicationRecord
include Accessible, AutoPostponing, Broadcastable, Cards, Entropic, Filterable, Publishable, Triageable
belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :account, default: -> { creator.account }
has_rich_text :public_description
+10 -4
View File
@@ -11,7 +11,7 @@ module Board::Accessible
end
def grant_to(users)
Access.insert_all Array(users).collect { |user| { board_id: proxy_association.owner.id, user_id: user.id } }
Access.insert_all Array(users).collect { |user| { id: ActiveRecord::Type::Uuid.generate, board_id: proxy_association.owner.id, user_id: user.id, account_id: proxy_association.owner.account.id } }
end
def revoke_from(users)
@@ -58,7 +58,7 @@ module Board::Accessible
end
def grant_access_to_everyone
accesses.grant_to(User.active) if all_access_previously_changed?(to: true)
accesses.grant_to(account.users.active) if all_access_previously_changed?(to: true)
end
def mentions_for_user(user)
@@ -66,11 +66,14 @@ module Board::Accessible
#
# 1. Mention->Card
# 2. Mention->Comment->Card
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
board_id_binary = uuid_type.serialize(id)
user.mentions
.joins("LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'")
.joins("LEFT JOIN comments ON mentions.source_id = comments.id AND mentions.source_type = 'Comment'")
.joins("LEFT JOIN cards AS comment_cards ON comments.card_id = comment_cards.id")
.where("(mentions.source_type = 'Card' AND cards.board_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.board_id = ?)", id, id)
.where("(mentions.source_type = 'Card' AND cards.board_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.board_id = ?)", board_id_binary, board_id_binary)
end
def notifications_for_user(user)
@@ -81,6 +84,9 @@ module Board::Accessible
#
# Notification->Event->Mention->Card and Notification->Event->Mention->Comment->Card are
# handled by destroying mentions_for_user.
uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy)
board_id_binary = uuid_type.serialize(id)
user.notifications
.joins("LEFT JOIN events ON notifications.source_id = events.id AND notifications.source_type = 'Event'")
.joins("LEFT JOIN cards AS event_cards ON events.eventable_id = event_cards.id AND events.eventable_type = 'Card'")
@@ -88,7 +94,7 @@ module Board::Accessible
.joins("LEFT JOIN cards AS event_comment_cards ON event_comments.card_id = event_comment_cards.id")
.where("(notifications.source_type = 'Event' AND events.eventable_type = 'Card' AND event_cards.board_id = ?) OR
(notifications.source_type = 'Event' AND events.eventable_type = 'Comment' AND event_comment_cards.board_id = ?)",
id, id)
board_id_binary, board_id_binary)
end
def watches_for(user)
+1 -1
View File
@@ -7,7 +7,7 @@ module Board::Entropic
end
def entropy
super || Account.sole.entropy
super || account.entropy
end
def auto_postpone_period=(new_value)
+1
View File
@@ -1,4 +1,5 @@
class Board::Publication < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :board
has_secure_token :key
+17 -3
View File
@@ -3,7 +3,8 @@ class Card < ApplicationRecord
Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable
belongs_to :board, touch: true
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :comments, dependent: :destroy
@@ -12,17 +13,22 @@ class Card < ApplicationRecord
has_rich_text :description
before_save :set_default_title, if: :published?
before_create :assign_number
after_save -> { board.touch }, if: :published?
after_update :handle_board_change, if: :saved_change_to_board_id?
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order last_active_at: :desc, id: :desc }
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy ], not_now: [ :user ]).with_rich_text_description_and_embeds }
scope :indexed_by, ->(index) do
case index
when "stalled" then stalled
when "postponing_soon" then postponing_soon
when "closed" then closed.recently_closed_first
when "closed" then closed
when "not_now" then postponed.latest
when "golden" then golden
when "draft" then drafted
@@ -45,6 +51,10 @@ class Card < ApplicationRecord
self
end
def to_param
number.to_s
end
def move_to(new_board)
transaction do
card.update!(board: new_board)
@@ -62,7 +72,7 @@ class Card < ApplicationRecord
end
def handle_board_change
old_board = Board.find_by(id: board_id_before_last_save)
old_board = account.boards.find_by(id: board_id_before_last_save)
transaction do
update! column: nil
@@ -80,4 +90,8 @@ class Card < ApplicationRecord
def grant_access_to_assignees
board.accesses.grant_to(assignees)
end
def assign_number
self.number ||= account.increment!(:cards_count).cards_count
end
end
+1
View File
@@ -1,3 +1,4 @@
class Card::ActivitySpike < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
end
+1 -1
View File
@@ -55,6 +55,6 @@ class Card::ActivitySpike::Detector
end
def last_event
card.events.last
card.events.order(created_at: :desc).first
end
end
+1
View File
@@ -1,4 +1,5 @@
class Card::Engagement < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, class_name: "::Card", touch: true
validates :status, presence: true, inclusion: { in: %w[doing on_deck] }
+7 -4
View File
@@ -4,16 +4,19 @@ module Card::Entropic
included do
scope :due_to_be_postponed, -> do
active
.joins(board: :account)
.left_outer_joins(board: :entropy)
.where("last_active_at <= DATETIME('now', '-' || COALESCE(entropies.auto_postpone_period, ?) || ' seconds')",
Account.sole.entropy.auto_postpone_period)
.joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id")
.where("last_active_at <= DATE_SUB(NOW(), INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)")
end
scope :postponing_soon, -> do
active
.joins(board: :account)
.left_outer_joins(board: :entropy)
.where("last_active_at > DATETIME('now', '-' || COALESCE(entropies.auto_postpone_period, ?) || ' seconds')", Account.sole.entropy.auto_postpone_period)
.where("last_active_at <= DATETIME('now', '-' || CAST(COALESCE(entropies.auto_postpone_period, ?) * 0.75 AS INTEGER) || ' seconds')", Account.sole.entropy.auto_postpone_period)
.joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id")
.where("last_active_at > DATE_SUB(NOW(), INTERVAL COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) SECOND)")
.where("last_active_at <= DATE_SUB(NOW(), INTERVAL CAST(COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75 AS SIGNED) SECOND)")
end
delegate :auto_postpone_period, to: :board
+1 -1
View File
@@ -5,7 +5,7 @@ module Card::Golden
has_one :goldness, dependent: :destroy, class_name: "Card::Goldness"
scope :golden, -> { joins(:goldness) }
scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL") }
scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL").preload(:goldness) }
end
def golden?
+1
View File
@@ -1,3 +1,4 @@
class Card::Goldness < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
end
+1
View File
@@ -1,4 +1,5 @@
class Card::NotNow < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, class_name: "::Card", touch: true
belongs_to :user, optional: true
end
+1 -1
View File
@@ -24,7 +24,7 @@ module Card::Promptable
* Board id: #{board_id}
* Board name: #{board.name}
* Number of comments: #{comments.count}
* Path: #{card_path(self, script_name: Account.sole.slug)}
* Path: #{card_path(self, script_name: account.slug)}
END OF CARD #{id}
PROMPT

Some files were not shown because too many files have changed in this diff Show More