Merge branch 'main' into change-collection

* main: (25 commits)
  Add controller to retarget links
  Use more complete regexp
  dep: bundle update
  refactor: Extract a Card::Entropy concern
  Autolink emails and URLs at rendering time
  Remove the "mirror" storage service
  Update production storage to write to the Pure blob store
  Clean up the purestorage config, and add explanatory comments.
  Drop the action_text_markdowns table
  Mount mission control under `/admin/jobs`
  dep: add mission_control-jobs
  Flip the storage mirror to write to Pure as the primary
  Production mirrors Active Storage to `:purestorage`
  Make sure Sentry is configured for the beta env.
  Update lexical
  update lexical
  update lexical
  Create a distinct "beta" environment
  Revert "Patch for cards missing description"
  Add a kamal "ssh" alias
  ...
This commit is contained in:
Jason Zimdars
2025-06-02 09:34:01 -05:00
49 changed files with 480 additions and 569 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Beta/SECRET_KEY_BASE Beta/OPEN_AI_API_KEY)
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Beta/RAILS_MASTER_KEY)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS)
OPEN_AI_API_KEY=$(kamal secrets extract OPEN_AI_API_KEY $SECRETS)
RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)
+2 -2
View File
@@ -1,5 +1,5 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/SECRET_KEY_BASE)
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Beta/RAILS_MASTER_KEY)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS)
RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)
+2 -3
View File
@@ -1,6 +1,5 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/SECRET_KEY_BASE Production/OPEN_AI_API_KEY)
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/RAILS_MASTER_KEY)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS)
OPEN_AI_API_KEY=$(kamal secrets extract OPEN_AI_API_KEY $SECRETS)
RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)
+4 -2
View File
@@ -2,7 +2,6 @@ source "https://rubygems.org"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
gem "rails", github: "rails/rails", branch: "main"
gem "active_record-tenanted", bc: "active_record-tenanted"
# Assets & front end
gem "importmap-rails"
@@ -11,6 +10,7 @@ gem "stimulus-rails"
gem "turbo-rails"
# Deployment and drivers
gem "active_record-tenanted", bc: "active_record-tenanted"
gem "bootsnap", require: false
gem "kamal", require: false
gem "puma", ">= 5.0"
@@ -30,8 +30,10 @@ gem "jbuilder"
gem "actiontext-lexical", bc: "actiontext-lexical"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
# Telemetry and logging
# Telemetry, logging, and operations
gem "mission_control-jobs"
gem "sentry-ruby"
gem "sentry-rails"
gem "rails_structured_logging", bc: "rails-structured-logging"
+56 -24
View File
@@ -1,13 +1,13 @@
GIT
remote: https://github.com/basecamp/actiontext-lexical
revision: 9328297709d532fcb4cb5e47cfbddefda34d7c32
revision: 7f327ddd60f4902ac60bb426495e85b31b6f0ad2
specs:
actiontext-lexical (0.1.0)
rails (>= 8.0.2)
GIT
remote: https://github.com/basecamp/active_record-tenanted
revision: 523947a0f0f49f66a8a62eb93e9ba1a345b3076b
revision: f6fca189655ae6609d10ecad7cceed61592320c4
specs:
active_record-tenanted (0.1.0)
activerecord (>= 8.1.alpha)
@@ -17,28 +17,29 @@ GIT
GIT
remote: https://github.com/basecamp/rails-structured-logging
revision: fc810cc48ac54a07722013149e104ceb8ec9a7e3
revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6
specs:
rails_structured_logging (0.2.0)
rails_structured_logging (0.2.1)
json
rails (>= 6.0.0)
GIT
remote: https://github.com/crmne/ruby_llm.git
revision: 8412d51e869f1ee8e5229831a9a0bb5e9b8f3847
revision: 74305703dcdcdcf8b2adefd8236184bcffc83910
specs:
ruby_llm (1.2.0)
ruby_llm (1.3.0rc1)
base64
event_stream_parser (~> 1)
faraday (~> 2)
faraday-multipart (~> 1)
faraday-net_http (~> 3)
faraday-retry (~> 2)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
GIT
remote: https://github.com/rails/rails.git
revision: c1d7d56e831acf0aff5f3e27a6b384f543f358d0
revision: 0ac3fba6ee5f7d1680024a4925eee6069095487b
branch: main
specs:
actioncable (8.1.0.alpha)
@@ -144,11 +145,29 @@ GEM
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
base64 (0.2.0)
aws-eventstream (1.3.2)
aws-partitions (1.1109.0)
aws-sdk-core (3.224.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.101.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.188.0)
aws-sdk-core (~> 3, >= 3.224.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.4.0)
bigdecimal (3.1.9)
benchmark (0.4.1)
bigdecimal (3.2.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.0.2)
@@ -207,7 +226,7 @@ GEM
addressable (>= 2.5.0)
globalid (1.2.1)
activesupport (>= 6.1)
hashdiff (1.1.2)
hashdiff (1.2.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
@@ -225,7 +244,8 @@ GEM
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.11.3)
jmespath (1.6.2)
json (2.12.2)
kamal (2.6.1)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -237,7 +257,7 @@ GEM
sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
language_server-protocol (3.17.0.4)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
@@ -256,6 +276,16 @@ GEM
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
mission_control-jobs (1.0.2)
actioncable (>= 7.1)
actionpack (>= 7.1)
activejob (>= 7.1)
activerecord (>= 7.1)
importmap-rails (>= 1.2.1)
irb (~> 1.13)
railties (>= 7.1)
stimulus-rails
turbo-rails
msgpack (1.8.0)
multipart-post (2.4.1)
net-http (0.6.0)
@@ -304,7 +334,7 @@ GEM
psych (5.2.6)
date
stringio
public_suffix (6.0.1)
public_suffix (6.0.2)
puma (6.6.0)
nio4r (~> 2.0)
raabro (1.4.0)
@@ -327,7 +357,7 @@ GEM
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rainbow (3.1.1)
rake (13.2.1)
rake (13.3.0)
rb_sys (0.9.106)
rdoc (6.14.0)
erb
@@ -342,7 +372,7 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.75.4)
rubocop (1.75.8)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -360,12 +390,12 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.31.0)
rubocop-rails (2.32.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
@@ -376,7 +406,7 @@ GEM
logger
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.32.0)
selenium-webdriver (4.33.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -452,7 +482,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.7.7)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -470,6 +500,7 @@ PLATFORMS
DEPENDENCIES
actiontext-lexical!
active_record-tenanted!
aws-sdk-s3
bcrypt (~> 3.1.7)
bootsnap
brakeman
@@ -481,6 +512,7 @@ DEPENDENCIES
importmap-rails
jbuilder
kamal
mission_control-jobs
platform_agent
propshaft
puma (>= 5.0)
+5 -7
View File
@@ -23,12 +23,10 @@
--border: 1px solid var(--color-ink-lighter);
/* Shadows */
--shadow: 0 0 0 1px oklch(var(--lch-black) / 0.02),
0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.2),
0 .4em 2.4em -1em oklch(var(--lch-black) / 0.3),
0 .4em .8em -1.2em oklch(var(--lch-black) / 0.4),
0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.5),
0 1.2em 1.6em -2em oklch(var(--lch-black) / 0.6);
--shadow: 0 0 0 1px oklch(var(--lch-black) / 5%),
0 0.2em 0.2em oklch(var(--lch-black) / 5%),
0 0.4em 0.4em oklch(var(--lch-black) / 5%),
0 0.8em 0.8em oklch(var(--lch-black) / 5%);
/* Components */
--btn-size: 2.65em;
@@ -186,7 +184,7 @@
--color-code-token__punctuation: oklch(var(--lch-ink-dark));
--color-code-token__selector: oklch(var(--lch-green-dark));
--color-code-token__variable: oklch(var(--lch-red-dark));
@media (prefers-color-scheme: dark) {
--lch-canvas: 20% 0.0195 232.58;
+2 -2
View File
@@ -52,12 +52,12 @@
}
}
.bubble--closing {
.bubble--considering {
inset-inline-start: calc(-1 * var(--bubble-gap));
translate: -100% -50%;
}
.bubble--considering {
.bubble--doing {
inset-inline-end: calc(-1 * var(--bubble-gap));
translate: 100% -50%;
}
+16 -1
View File
@@ -101,7 +101,6 @@
--text-small: 1.1em;
background-color: var(--color-canvas);
box-shadow: 0 0 0 1px var(--color-ink-lighter);
line-height: 1.2;
.avatar {
@@ -122,6 +121,22 @@
display: none;
}
.card__collection {
background-color: var(--color-canvas);
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
padding: 0 0 0 var(--inline-space);
}
.card__collection-name {
border-inline-start: 1px solid color-mix(in srgb, var(--color-ink) 33%, var(--color-canvas));
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
}
.card__header {
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
margin: 0 0 calc(-0.5 * var(--card-padding-inline)) 0;
}
.card__title {
margin-block-start: 0.1em;
min-block-size: 0;
+5 -5
View File
@@ -25,6 +25,10 @@
.avatar {
--avatar-size: 2.75em;
}
@media (prefers-color-scheme: dark) {
box-shadow: 0 0 0 1px var(--color-ink-lighter);
}
}
/* Children
@@ -69,11 +73,6 @@
margin-block-start: calc(-1 * var(--card-padding-block));
margin-inline-start: calc(-1 * var(--card-padding-inline));
min-inline-size: 0;
.cards--considering & {
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
margin: 0 0 calc(-0.5 * var(--card-padding-inline)) 0;
}
}
.card__collection {
@@ -211,6 +210,7 @@
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-link) 20%, var(--color-canvas));
color: var(--btn-color);
font-weight: 700;
transition: none;
}
.card__move-button {
+1 -1
View File
@@ -11,7 +11,7 @@ class UploadsController < ApplicationController
def show
expires_in 5.minutes, public: true
redirect_to @attachment.url
redirect_to @attachment.url, allow_other_host: true
end
private
+46
View File
@@ -0,0 +1,46 @@
module HtmlHelper
def format_html(html)
fragment = Nokogiri::HTML.fragment(html)
auto_link(fragment)
fragment.to_html.html_safe
end
private
EXCLUDED_ELEMENTS = %w[ a figcaption pre code ]
EMAIL_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])
def auto_link(fragment)
fragment.traverse do |node|
next unless auto_linkable_node?(node)
content = node.text
linked_content = content.dup
auto_link_urls(linked_content)
auto_link_emails(linked_content)
if linked_content != content
node.replace(Nokogiri::HTML.fragment(linked_content))
end
end
end
def auto_linkable_node?(node)
node.text? && node.ancestors.none? { |ancestor| EXCLUDED_ELEMENTS.include?(ancestor.name) }
end
def auto_link_urls(linked_content)
linked_content.gsub!(URL_REGEXP) do |match|
%(<a href="#{match}" rel="noreferrer">#{match}</a>)
end
end
def auto_link_emails(text)
text.gsub!(EMAIL_REGEXP) do |match|
%(<a href="mailto:#{match}">#{match}</a>)
end
end
end
@@ -0,0 +1,15 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.querySelectorAll("a").forEach(this.#retargetLink.bind(this))
}
#retargetLink(link) {
link.target = this.#targetsSameDomain(link) ? "_top" : "_blank"
}
#targetsSameDomain(link) {
return link.href.startsWith(window.location.origin)
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
class Card < ApplicationRecord
include Assignable, Colored, Engageable, Eventable, Golden,
Mentions, Pinnable, Closeable, Readable, Searchable, Staged,
Statuses, Taggable, Watchable
include Assignable, Colored, Engageable, Entropy, Eventable,
Golden, Mentions, Pinnable, Closeable, Readable, Searchable,
Staged, Statuses, Taggable, Watchable
belongs_to :collection, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
-26
View File
@@ -1,8 +1,6 @@
module Card::Closeable
extend ActiveSupport::Concern
AUTO_CLOSE_REMINDER_BEFORE = 7.days
included do
has_one :closure, dependent: :destroy
@@ -10,30 +8,6 @@ module Card::Closeable
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) }
scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") }
delegate :auto_closing?, :auto_close_period, to: :collection
end
class_methods do
def auto_close_all_due
due_to_be_closed.find_each do |card|
card.close(user: User.system, reason: "Closed")
end
end
end
def auto_close_at
last_active_at + auto_close_period if auto_closing? && last_active_at
end
def days_until_close
(auto_close_at.to_date - Date.current).to_i if auto_close_at
end
def closing_soon?
considering? && auto_closing? && Time.current >= auto_close_at - AUTO_CLOSE_REMINDER_BEFORE
end
def closed?
+8 -14
View File
@@ -1,15 +1,11 @@
module Card::Engageable
extend ActiveSupport::Concern
STAGNATED_AFTER = 30.days
AUTO_RECONSIDER_REMINDER_BEFORE = 7.days
included do
has_one :engagement, dependent: :destroy, class_name: "Card::Engagement"
scope :considering, -> { published_or_drafted_by(Current.user).open.where.missing(:engagement) }
scope :doing, -> { published.open.joins(:engagement) }
scope :stagnated, -> { doing.where(last_active_at: ..STAGNATED_AFTER.ago) }
scope :by_engagement_status, ->(status) do
case status.to_s
@@ -19,16 +15,6 @@ module Card::Engageable
end
end
class_methods do
def auto_reconsider_all_stagnated
stagnated.find_each(&:reconsider)
end
end
def auto_reconsider_at
last_active_at + STAGNATED_AFTER if last_active_at
end
def doing?
open? && published? && engagement.present?
end
@@ -37,6 +23,14 @@ module Card::Engageable
open? && published? && engagement.blank?
end
def engagement_status
if doing?
"doing"
elsif considering?
"considering"
end
end
def engage
unless doing?
transaction do
+47
View File
@@ -0,0 +1,47 @@
module Card::Entropy
extend ActiveSupport::Concern
AUTO_RECONSIDER_PERIOD = 30.days
ENTROPY_REMINDER_BEFORE = 7.days
included do
scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) }
scope :stagnated, -> { doing.where(last_active_at: ..AUTO_RECONSIDER_PERIOD.ago) }
scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") }
delegate :auto_closing?, :auto_close_period, to: :collection
end
class_methods do
def auto_close_all_due
due_to_be_closed.find_each do |card|
card.close(user: User.system, reason: "Closed")
end
end
def auto_reconsider_all_stagnated
stagnated.find_each(&:reconsider)
end
end
def subject_to_entropy?
doing? || (auto_closing? && considering?)
end
def auto_close_at
last_active_at + auto_close_period if auto_closing? && last_active_at
end
def days_until_close
(auto_close_at.to_date - Date.current).to_i if auto_close_at
end
def auto_reconsider_at
last_active_at + AUTO_RECONSIDER_PERIOD if last_active_at
end
def days_until_reconsider
(auto_reconsider_at.to_date - Date.current).to_i if auto_reconsider_at
end
end
+1 -1
View File
@@ -28,7 +28,7 @@
<% end %>
</div>
<div class="comment__body rich-text-content txt-align-start" data-controller="syntax-highlight">
<div class="comment__body rich-text-content txt-align-start" data-controller="syntax-highlight retarget-links">
<%= comment.body %>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %>
</h1>
<div class="card__description rich-text-content margin-block-half rich-text-content" data-controller="syntax-highlight">
<div class="card__description rich-text-content margin-block-half rich-text-content" data-controller="syntax-highlight retarget-links">
<%= card.description %>
</div>
</div>
+2 -6
View File
@@ -29,12 +29,8 @@
<%= render "cards/display/common/background", card: card %>
<% if card.auto_closing? %>
<% if card.considering? %>
<%= render "cards/display/preview/bubble", card: card, style: "closing" %>
<% elsif card.doing? %>
<%= render "cards/display/preview/bubble", card: card, style: "considering" %>
<% end %>
<% if card.subject_to_entropy? %>
<%= render "cards/display/preview/bubble", card: card %>
<% end %>
<% end %>
<% end %>
@@ -1,6 +1,6 @@
<div hidden class="bubble bubble--<%= style %>"
<div hidden class="bubble bubble--<%= card.engagement_status %>"
data-controller="bubble" data-action="turbo:morph-element->bubble#update"
data-bubble-reminder-before-value="<%= Card::AUTO_CLOSE_REMINDER_BEFORE.in_days.to_i %>"
data-bubble-reminder-before-value="<%= Card::ENTROPY_REMINDER_BEFORE.in_days.to_i %>"
data-bubble-closes-at-value="<%= card.auto_close_at.iso8601 %>"
data-bubble-entropy-action-value="<%= card_entropy_action(card) %>">
<svg viewBox="0 0 200 100">
@@ -1,3 +1,3 @@
<div class="action-text-content">
<%= yield -%>
<%= format_html yield -%>
</div>
+4 -1
View File
@@ -12,5 +12,8 @@ development:
test:
adapter: test
production:
production: &production
<<: *cable
beta:
<<: *production
+4 -1
View File
@@ -17,6 +17,9 @@ development:
test:
<<: *default_options
production:
production: &production
<<: *default_connection
<<: *default_options
beta:
<<: *production
-1
View File
@@ -1 +0,0 @@
5ZoHBjwBHPiVvPcnI4khzagL3ZR54mfvv5VdRLjDicl0s/6hGKd88rwmSb/8WOITFSDQZntfFcJ4/xlH8ZLr2az51/7CJr9J1QSJUCBexDtTpqa4g7/+s2TW1YDrLg3LybtVzuGcTVL2YE4llcW+5RLI6JuioAQQ0Sx9hGqd6n5/safGW/OXrBPS198b3YmPeAkSm4uA4L7yOmQYaeweJp1p47WSrgh5bnesttx+ey0L7nMSwWKj/pBFEP5N0ELQXm4G38RYf3SUsoAQkUl+TIsMHvFz3/toeS0+tuEvKeDQYlvXCyVTK4XHsvbu8pkOvZGBpDgkvW3aMOBTf/Bsrv0ylU1gF3pGs2Oc90KOmKxBmHbuOOS5VpNTYhZ6IPEaMz9t5rDYlYVupPQ6TTNrn4jAvq6f--InMaBENysICzAQUu--mMpwA+kXMLs3j/zWoA0NMA==
+1
View File
@@ -0,0 +1 @@
I2oqW56RTDrRxQ5HUGXGUbSiVoeICiF6tjSo1oxmGuZKE6wu99S+RvQ6Mf0enCQdbHG0osoCpUvu7iuOMEA3X6vO7ku1YFbCDKugu6tY913bzRl166SWZdSlEwtcr6wtmUkXLoWfDzOt98lJ03nU8YbeDC2AzuBqcYWS2qjqpFrrDK8WQI7IukM4iFEDjesW2Noekyy6z+b5+DxB2lkCMFgim4ExW/ndFmrvDnt+QeR+rB9XW9BF6CEg5pPsBQ9EwtICsacCWdvFFOZ8EaNoOuF+hKIHoKP60UCfQj5EUir/r5pGE0Z83z91+UpdpVCuGvh62uSZoZOjdGAplg/DWWKpGcj5JOA7V2yyGEMlz7NueBInG8t/eEyeX/98fAy4TNUxxvncNB+xsmgPJIqlek7vXtYe06AMwrRvac4QSSpUuwWuKUV8JF4gYT5Gp92rzqmmwsTmjvEMiPlnnVYH83GCtd2f1Se7EkDVTvgLA3idnjLvFCelAU4Vdo9/pLqG+0xorSxQ4D/UXqykxtTSjz6pC2P8J1g6T91TQhcH4tDFuZICOYLyFV8F5OD3jPaP+ml01oME42FIKmyhvcNhMLqluUvFqqtOxgWp54jCnde6MBHJ9W7gejwfSmjyD+pr/5NXgaGaTq9h6NMdMkr0Bis6Cc/EgHZKBpJx6ARH1ulcp9uZ9tBjNCePBNxATrJLKwiXh0ZTLDfDTkgdznkj8u+FAKclxjIZ63td3hGe--5kL30BIpNtC7wcsf--YaV9PXL74Wl2ePaAdT5Crg==
+1 -1
View File
@@ -1 +1 @@
TebKB7S5umO6AQRNOZWHMihQAlRay0wPXY637HdvynV9d34ALokzb765aykzZYsUmeHdvFnSHdweRQG60NR7V3sXNL09h38pWo0JhcGkwmtEAe908mZRjC0JjXamOx0AgohRdfmjwlI9MROfjTwrKq0CtdIien7hNZDqUxEbWOjSX2RiPL65Ro++iAnvA9ezUoHW47TPPnXDiFq4I37KPVhl1Ze1bPP02VebysUB/K+nsy4GsRdl3jxLXQ==--7ie1Lx8/EJvL2Jbc--WJAy/xEQeki9ceKjC5Si1A==
e6HcyVNJvgdJGTCcvW8FDHQ9pHYmTgNW2/71Espdwxd5LQX/Xb9+VkeqpEAL8ceFJo6ro49mMs85G2YngKbbXEh//8+XSS0inMR/kh9wTV3f6YBWnLVBbhrOnYgPcQ8vedsgxLG2Cs4y7ipfeT/dp9bb5m4IxV0OPiUjVFFJuAuGkMJt3pE7D6alqL1ACRxazV9UKV5WKEBfaQYeKpJ++5f86Nls1EwKo5tB1lJed9P5n2yH7meX/QyxjgQd38CIaPwp+F92y5DLKJD5MZRMJGgWf6MT6096LhjEC3vvtS3wwT/eDVcVKTplRkEszj7BZEfOs9Ondb9EVtbXmeoHYSgpaOq9/nEwtho5Uw==--KlHZy1apg3u1XxFs--N2ehRobLHNz+Sepc8p4EIA==
+1
View File
@@ -0,0 +1 @@
Qkkt3krcGlOh76//7TmY5rXz31mhL3NNNiO6TCcRhx4lT6uZ8sLjW7X0yp2wZ30y2cJBeIsNMeOhL47JTpu0Z4d0FPtwgH4bzzXAiab0/YrfMjxyBt7/K1DzW/bePUvakJggIvVGGRA1NB1tPkwziQ4dgA34E/UUPAxUofFaoCIFVABmqt4DFg0DKYB8tImDDjZ5xUfSNxrKj3pH5mOIU9jqSK6LtBELFZcNxrJOI0UEgayRNwYAs44ujdM2/2zrAWlUCFZ3NnMU4bBJW02/d6LaxHtB809u8mx3bChEzWMtZB9CvpbRH7u6XfZJR1YGFztcNguzRbNX5x9pKZImAuAhVFKR7Fbi12Hg3w7U3A/YovwmyhHK6ksfOzYlYoDUTBnGYVwiqR16naaHCMekomK+uo0qtOn83RQkLuuqFJshPXn/lW8Rggt4dq/0THEl7Qj+dumadlWDVEgxhipI031RSBF1toNWw/zwaC31Ord1YXD8ZC09sPoj8Zd4hD2o2a0IUuIMXA8w5/2+AmdU8wF66opdKGdsZDRjM2Bul8aAbJ7/iV7xTuvlE4LmzXGArMvivIZ0GwrN3YAsDta2Y3ufibq7WZzSCOrpbPKJ9CEGg4a2AN8UGaGZ5ZEAYg8lhDgsZMjHRkGNuxk8QkDiVFDyNoI7H9ZQqbw+L1AJRTfDIcNB/9h5n+//Nk4ylLF1lnI7y/IQA/AuSQ+UN+okt3RhLugwGvWrs49MExJStLHpGveSoQ==--MQ2JycR3X6LIiKSY--45C7bleKX9FMGv4fc+G1yw==
+4 -1
View File
@@ -37,7 +37,7 @@ test:
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
tenanted: true
production:
production: &production
primary:
<<: *default
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
@@ -62,3 +62,6 @@ production:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
beta:
<<: *production
+3 -1
View File
@@ -10,6 +10,9 @@ proxy:
hosts:
- fizzy.37signals.works # the single tenant is named "fizzy"
env:
clear:
RAILS_ENV: beta
x-beamer-accessory: &beamer-accessory
image: basecamp/beamer
@@ -22,7 +25,6 @@ x-beamer-accessory: &beamer-accessory
volumes:
- fizzy:/storage
accessories:
beamer-primary:
<<: *beamer-accessory
+3
View File
@@ -16,6 +16,9 @@ proxy:
ssh:
user: app
env:
clear:
RAILS_ENV: production
x-beamer-accessory: &beamer-accessory
image: basecamp/beamer
+2 -2
View File
@@ -23,8 +23,8 @@ builder:
env:
secret:
- SECRET_KEY_BASE
- OPEN_AI_API_KEY
- RAILS_MASTER_KEY
aliases:
console: app exec -i --reuse "bin/rails console"
ssh: app exec -i --reuse /bin/bash
+8
View File
@@ -0,0 +1,8 @@
require_relative "production"
Rails.application.configure do
config.action_mailer.default_url_options = { host: "%{tenant}.37signals.works" }
# Let's keep beta on local disk. See https://github.com/basecamp/fizzy/pull/557 for context.
config.active_storage.service = :local
end
+1 -1
View File
@@ -31,7 +31,7 @@ Rails.application.configure do
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
config.active_storage.service = :purestorage
# Mount Action Cable outside main process or domain.
# config.action_cable.mount_path = nil
+1 -1
View File
@@ -1,4 +1,4 @@
if Rails.env.production? && ENV["SKIP_TELEMETRY"].blank?
if (Rails.env.production? || Rails.env.beta?) && ENV["SKIP_TELEMETRY"].blank?
Sentry.init do |config|
config.dsn = "https://ca338fb1fe6f677d6aeec2336a86f0ee@o33603.ingest.us.sentry.io/4508093839179776"
config.breadcrumbs_logger = %i[ active_support_logger http_logger ]
-5
View File
@@ -1,5 +0,0 @@
Rails.application.config.after_initialize do
%w[ db files ].each do |dir|
Rails.root.join("storage", dir).mkpath
end
end
+4 -1
View File
@@ -14,5 +14,8 @@ development:
test:
<<: *default
production:
production: &production
<<: *default
beta:
<<: *production
+4 -1
View File
@@ -1,4 +1,4 @@
production:
production: &production
auto_close_all_due:
class: Card::AutoCloseAllDueJob
schedule: every hour
@@ -14,3 +14,6 @@ production:
delete_unused_tags:
class: DeleteUnusedTagsJob
schedule: every day at 04:00
beta:
<<: *production
+4
View File
@@ -123,4 +123,8 @@ Rails.application.routes.draw do
root "events#index"
namespace :admin do
mount MissionControl::Jobs::Engine, at: "/jobs"
end
end
+13 -28
View File
@@ -1,36 +1,21 @@
test:
service: Disk
root: <%= Rails.root.join("tmp/storage/%{tenant}") %>
tenanted: true
local:
service: Disk
root: <%= Rails.root.join("storage", "tenants", Rails.env, "%{tenant}", "files") %>
tenanted: true
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# collection: your_own_collection-<%= Rails.env %>
# Remember not to checkin your GCS keyfile to a repository
# google:
# service: GCS
# project: your_project
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
# collection: your_own_collection-<%= Rails.env %>
# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
# service: AzureStorage
# storage_account_name: your_account_name
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
# container: your_container_name-<%= Rails.env %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]
# We have "development", "staging", and "production" buckets configured. Note that we don't have a
# "beta" bucket. (As of 2025-06-01.)
purestorage:
service: S3
bucket: fizzy-<%= Rails.env %>-activestorage
endpoint: "https://storage.basecamp.com"
ssl_verify_peer: false # FIXME: using self-signed cert internally
force_path_style: true
request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support
response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support
region: us-east-1 # default region required for signer
access_key_id: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:active_storage, :purestorage_service, :secret_access_key) %>
@@ -0,0 +1,5 @@
class DropActionTextMarkdowns < ActiveRecord::Migration[8.1]
def change
drop_table :action_text_markdowns
end
end
Generated
+1 -12
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) do
ActiveRecord::Schema[8.1].define(version: 2025_06_01_161653) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -29,16 +29,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) do
t.datetime "updated_at", null: false
end
create_table "action_text_markdowns", force: :cascade do |t|
t.text "content", default: "", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.integer "record_id", null: false
t.string "record_type", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id"], name: "index_action_text_markdowns_on_record"
end
create_table "action_text_rich_texts", force: :cascade do |t|
t.text "body"
t.datetime "created_at", null: false
@@ -308,7 +298,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_23_084734) do
t.datetime "created_at", null: false
t.string "title"
t.datetime "updated_at", null: false
t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true
end
create_table "users", force: :cascade do |t|
+32 -94
View File
@@ -114,57 +114,22 @@ columns:
collation:
comment:
- *9
action_text_markdowns:
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content
cast_type: &11 !ruby/object:ActiveRecord::Type::Text
name: body
cast_type: &15 !ruby/object:ActiveRecord::Type::Text
true: t
false: f
precision:
scale:
limit:
sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &16 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: TEXT
type: :text
limit:
precision:
scale:
'null': false
default: ''
default_function:
collation:
comment:
- *5
- *6
- *10
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: record_id
cast_type: *1
sql_type_metadata: *2
'null': false
default:
default_function:
collation:
comment:
- &13 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: record_type
cast_type: *7
sql_type_metadata: *8
'null': false
default:
default_function:
collation:
comment:
- *9
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: body
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
@@ -173,16 +138,16 @@ columns:
- *5
- *6
- *10
- &16 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &13 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: record_id
cast_type: &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer
cast_type: &11 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer
precision:
scale:
limit:
max: 9223372036854775808
min: -9223372036854775808
sql_type_metadata: &15 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &12 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: bigint
type: :integer
limit:
@@ -193,14 +158,23 @@ columns:
default_function:
collation:
comment:
- *13
- &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: record_type
cast_type: *7
sql_type_metadata: *8
'null': false
default:
default_function:
collation:
comment:
- *9
active_storage_attachments:
- &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: blob_id
cast_type: *14
sql_type_metadata: *15
cast_type: *11
sql_type_metadata: *12
'null': false
default:
default_function:
@@ -209,8 +183,8 @@ columns:
- *5
- *6
- *10
- *16
- *13
- *14
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: slug
@@ -225,8 +199,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: byte_size
cast_type: *14
sql_type_metadata: *15
cast_type: *11
sql_type_metadata: *12
'null': false
default:
default_function:
@@ -277,8 +251,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: metadata
cast_type: *11
sql_type_metadata: *12
cast_type: *15
sql_type_metadata: *16
'null': true
default:
default_function:
@@ -445,8 +419,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: status
cast_type: *11
sql_type_metadata: *12
cast_type: *15
sql_type_metadata: *16
'null': false
default: creating
default_function:
@@ -524,8 +498,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auto_close_period
cast_type: *14
sql_type_metadata: *15
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
@@ -573,8 +547,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: line
cast_type: *11
sql_type_metadata: *12
cast_type: *15
sql_type_metadata: *16
'null': true
default:
default_function:
@@ -1005,7 +979,6 @@ columns:
primary_keys:
accesses: id
accounts: id
action_text_markdowns: id
action_text_rich_texts: id
active_storage_attachments: id
active_storage_blobs: id
@@ -1044,7 +1017,6 @@ primary_keys:
data_sources:
accesses: true
accounts: true
action_text_markdowns: true
action_text_rich_texts: true
active_storage_attachments: true
active_storage_blobs: true
@@ -1132,24 +1104,6 @@ indexes:
comment:
valid: true
accounts: []
action_text_markdowns:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: action_text_markdowns
name: index_action_text_markdowns_on_record
unique: false
columns:
- record_type
- record_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: action_text_rich_texts
@@ -2098,23 +2052,7 @@ indexes:
nulls_not_distinct:
comment:
valid: true
tags:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: tags
name: index_tags_on_account_id_and_title
unique: true
columns:
- title
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
tags: []
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
@@ -2199,4 +2137,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250523084734
version: 20250601161653
+47
View File
@@ -0,0 +1,47 @@
#! /usr/bin/env ruby
require_relative "../config/environment"
def migrate(source_service_name, target_service_name)
ApplicationRecord.with_each_tenant do |tenant|
puts "\n## #{tenant}"
report = { updated: 0, skipped: 0, errors: 0 }
if ActiveStorage::Blob.count == 0
puts "No blobs found, skipping."
next
end
ActiveStorage::Blob.service = source_service = ActiveStorage::Blob.services.fetch(source_service_name)
target_service = ActiveStorage::Blob.services.fetch(target_service_name)
ActiveStorage::Blob.find_each do |blob|
if target_service.name.to_sym == blob.service_name.to_sym
report[:skipped] += 1
putc "-"
elsif target_service.exist?(blob.key)
report[:skipped] += 1
putc "S"
else
begin
blob.open do |stream|
target_service.upload(blob.key, stream, checksum: blob.checksum)
end
report[:updated] += 1
putc "."
rescue ActiveStorage::FileNotFoundError
report[:errors] += 1
putc "E"
end
end
# Update the service name of the blob.
blob.update_column :service_name, target_service_name
end
puts
pp report
end
end
migrate :local, :purestorage
+28
View File
@@ -0,0 +1,28 @@
#! /usr/bin/env ruby
require_relative "../config/environment"
ApplicationRecord.with_each_tenant do |tenant|
puts "\n## #{tenant}"
report = { updated: 0, skipped: 0 }
ActiveStorage::Blob.find_each do |blob|
if blob.key.start_with?("#{tenant}/")
report[:skipped] += 1
else
blob.update_column :key, "#{tenant}/#{blob.key}"
report[:updated] += 1
end
end
pp report
disk_service = ActiveStorage::Blob.services.fetch(:local)
new_root = File.join(disk_service.root, tenant)
old_root = File.join("storage", "tenants", Rails.env, tenant, "files")
FileUtils.mkdir_p(new_root, verbose: true) unless File.directory?(new_root)
Dir.glob(File.join(old_root, "??")).each_slice(20) do |blob_dirs|
FileUtils.mv(blob_dirs, new_root, verbose: true)
end
end
-271
View File
@@ -1,271 +0,0 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
class EnactTenanting
CURRENT_DOMAIN = "https://fizzy.37signals.com"
TENANTED_DOMAIN = Rails.env.local? ? "http://%{tenant}.fizzy.localhost:3006" : "https://%{tenant}.fizzy.37signals.com"
PROJECT_MAPPING = {
693169841 => "37s", # Writebook improvements
693169850 => "37s", # Fizzy: Issues
693169853 => "37s", # Fizzy: 💡 IDEAS
693169858 => "37s", # Know It All: 💡 IDEAS
693169859 => "37s", # Cycle 1: BC4 Client access for templates
693169860 => "37s", # Know It All: Issues
693169862 => "37s", # Active Record Tenanting
693169863 => "37s", # Cycle 1: BC4 Hilltop View
693169865 => "37s", # [BC iOS] Search refactor
693169867 => "37s", # File icons refactor
693169870 => "37s", # HEY Preload Replies
693169872 => "37s", # [BC4 Android] Search Refactor
693169873 => "37s", # BC4
693169874 => "37s", # [HEY] ContentFilter pipeline rewrite
693169875 => "37s", # [Hey Calendar Android] Search
693169876 => "37s", # [HEY Calendar iOS] Search
693169877 => "37s", # [BC4] Turbo for comment and answer forms
693169842 => "dev", # Foobar
693169843 => "dev", # Test
693169851 => "dev", # Kevin
693169861 => "dev", # Testing space
693169869 => "dev", # Flower ideas
693169871 => "dev", # Mike's TODOs
693169856 => "qa" # QA Exploration
}
TENANTS = PROJECT_MAPPING.values.uniq
attr_reader :creation_counter, :update_counter
def initialize
@creation_counter = {}
@update_counter = {}
end
def up
setup
destroy_tenants
safety_check
copy_accounts_et_al
copy_buckets_et_al
copy_bubbles_et_al
copy_filters
copy_active_storage
cross_check
update_action_text_urls
pp [ "updated:", update_counter ]
hardlink_active_storage
end
def setup
ApplicationRecord.connects_to shards: { untenanted: { reading: :primary_original } }
end
def with_original_db(&block)
ApplicationRecord.connected_to(role: :reading, shard: :untenanted, &block)
end
def safety_check
buckets = with_original_db { Bucket.all.to_a }
unless buckets.map(&:id).sort == PROJECT_MAPPING.keys.sort
unknown_buckets = buckets.map(&:id) - PROJECT_MAPPING.keys
missing_buckets = PROJECT_MAPPING.keys - buckets.map(&:id)
raise "Surprising buckets. unknown #{unknown_buckets.inspect}, missing #{missing_buckets.inspect}"
end
end
def copy_accounts_et_al
account = with_original_db { Account.first }
users = with_original_db { User.all.to_a }
workflows = with_original_db { account.workflows.to_a }
workflow_stages = with_original_db { workflows.flat_map(&:stages) }
tags = with_original_db { account.tags.to_a }
TENANTS.each do |tenant|
ApplicationRecord.with_tenant(tenant) do
Account.create! name: tenant, id: account.id
upsert_all users
upsert_all workflows
upsert_all workflow_stages
upsert_all tags
end
end
end
def copy_buckets_et_al
PROJECT_MAPPING.each do |bucket_id, tenant|
bucket = with_original_db { Bucket.where(id: bucket_id).first }
accesses = with_original_db { bucket.accesses.to_a }
subscriptions = with_original_db { bucket.subscriptions.to_a }
ApplicationRecord.with_tenant(tenant) do
upsert_all bucket
upsert_all accesses
upsert_all subscriptions
end
end
end
def copy_bubbles_et_al
PROJECT_MAPPING.each do |bucket_id, tenant|
bubbles = with_original_db { Bucket.find(bucket_id).bubbles.to_a }
assignments = with_original_db { bubbles.flat_map(&:assignments) }
pops = with_original_db { bubbles.filter_map(&:pop) }
notifications = with_original_db { bubbles.flat_map(&:notifications) }
events = with_original_db { bubbles.flat_map(&:events) }
taggings = with_original_db { bubbles.flat_map(&:taggings) }
watches = with_original_db { bubbles.flat_map(&:watches) }
messages = with_original_db { bubbles.flat_map(&:messages) }
messageables = with_original_db { messages.filter_map(&:messageable) }
comments = messageables.select { _1.is_a?(Comment) }
markdowns = with_original_db { comments.map(&:markdown_body) }
reactions = with_original_db { comments.flat_map(&:reactions) }
ApplicationRecord.with_tenant(tenant) do
upsert_all bubbles
upsert_all assignments
upsert_all pops
upsert_all taggings
upsert_all watches
messageables.group_by(&:class).each do |klass, klass_messageables|
upsert_all klass_messageables
end
upsert_all markdowns
upsert_all reactions
upsert_all messages
upsert_all events
upsert_all notifications
end
end
end
def copy_filters
creation_counter["Filter"] = 0
TENANTS.each do |tenant|
buckets = ApplicationRecord.with_tenant(tenant) { Bucket.all.to_a }
filters = with_original_db do
Filter.all.filter_map do |filter| # hah no pun intended
next unless filter.buckets.empty? || buckets.any? { |b| filter.buckets.include?(b) }
filter.attributes.merge(filter.as_params)
end
end
ApplicationRecord.with_tenant(tenant) do
filters.each do |attr|
Filter.create! attr
end
end
creation_counter["Filter"] += filters.length
end
end
def copy_active_storage
attachments = with_original_db { ActiveStorage::Attachment.all.to_a }
blobs = with_original_db { ActiveStorage::Blob.all.to_a }
TENANTS.each do |tenant|
ApplicationRecord.with_tenant(tenant) do
upsert_all blobs
upsert_all attachments
end
end
end
def destroy_tenants
TENANTS.each do |tenant|
ApplicationRecord.destroy_tenant(tenant)
end
FileUtils.rm_rf "storage/tenants"
end
def cross_check
pp [ "created:", creation_counter ]
with_original_db do
assert_count User, User.count * TENANTS.length
assert_count Workflow, Workflow.count * TENANTS.length
assert_count Workflow::Stage, Workflow::Stage.count * TENANTS.length
assert_count Tag, Tag.count * TENANTS.length
assert_count Bucket, Bucket.count
assert_count Access, Access.count
assert_count Subscription, Subscription.count
assert_count Bubble, Bubble.count
assert_count Assignment, Assignment.count
assert_count Pop, Pop.count
assert_count EventSummary, EventSummary.count
assert_count Comment, Comment.count
assert_count Reaction, Reaction.count
assert_count Message, Message.count
assert_count Tagging, Tagging.count
assert_count Watch, Watch.count
assert_count Event, Event.count
assert_count Notification, Notification.count
assert_count ActiveStorage::Attachment, ActiveStorage::Attachment.count * TENANTS.length
assert_count ActiveStorage::Blob, ActiveStorage::Blob.count * TENANTS.length
raise "Filter count is off" unless creation_counter["Filter"] >= Filter.count
end
end
def update_action_text_urls
raise "No more markdown"
end
def hardlink_active_storage
files = Dir.glob("storage/files/*/*/*").map { _1.split("/")[2..].join("/") }
TENANTS.each do |tenant|
ApplicationRecord.with_tenant(tenant) do
destdir = ActiveStorage::Blob.service.root
files.each do |file|
FileUtils.mkdir_p File.join(destdir, File.dirname(file))
FileUtils.ln File.join("storage/files", file), File.join(destdir, file)
end
end
end
end
def upsert_all(originals)
originals = Array(originals)
return if originals.empty?
klass = originals.first.class
result = klass.upsert_all(originals.collect(&:attributes))
raise "Error upserting" unless result.rows.length == originals.length
creation_counter[klass.name] ||= 0
creation_counter[klass.name] += originals.length
nil
end
def assert_count(klass, expected)
actual = creation_counter[klass.name]
unless actual == expected
raise "#{klass} count is off: expected #{expected}, got #{actual}"
end
end
end
EnactTenanting.new.up
exit 0
+35
View File
@@ -0,0 +1,35 @@
require "test_helper"
class HtmlHelperTest < ActionView::TestCase
test "converts URLs into anchor tags" do
assert_equal_html \
%(<p>Check this: <a href="https://example.com" rel="noreferrer">https://example.com</a></p>),
format_html("<p>Check this: https://example.com</p>")
end
test "respect existing links" do
assert_equal_html \
%(<p>Check this: <a href="https://example.com">https://example.com</a></p>),
format_html("<p>Check this: <a href=\"https://example.com\">https://example.com</a></p>")
end
test "converts email addresses into mailto links" do
assert_equal_html \
%(<p>Contact us at <a href="mailto:support@example.com">support@example.com</a></p>),
format_html("<p>Contact us at support@example.com</p>")
end
test "respect existing linked emails" do
assert_equal_html \
%(<p>Contact us at <a href="mailto:support@example.com">support@example.com</a></p>),
format_html(%(<p>Contact us at <a href="mailto:support@example.com">support@example.com</a></p>))
end
test "don't autolink content in excluded elements" do
%w[ figcaption pre code ].each do |element|
assert_equal_html \
"<#{element}>Check this: https://example.com</#{element}>",
format_html("<#{element}>Check this: https://example.com</#{element}>")
end
end
end
-26
View File
@@ -26,30 +26,4 @@ class Card::CloseableTest < ActiveSupport::TestCase
cards(:logo).update! last_active_at: 2.day.ago
assert_equal (123-2).days.from_now, cards(:logo).auto_close_at
end
test "auto close all due" do
cards(:logo, :shipping).each(&:reconsider)
cards(:logo).update!(last_active_at: 1.day.ago - collections(:writebook).auto_close_period)
cards(:shipping).update!(last_active_at: 1.day.from_now - collections(:writebook).auto_close_period)
assert_difference -> { Card.closed.count }, +1 do
Card.auto_close_all_due
end
assert cards(:logo).reload.closed?
assert_not cards(:shipping).reload.closed?
end
test "don't auto close those cards where the collection has no auto close period" do
cards(:logo, :shipping).each(&:reconsider)
collections(:writebook).update auto_close_period: nil
assert_no_difference -> { Card.closed.count } do
Card.auto_close_all_due
end
assert_not cards(:logo).reload.closed?
end
end
+3 -17
View File
@@ -11,6 +11,9 @@ class Card::EngageableTest < ActiveSupport::TestCase
assert_not cards(:logo).considering?
assert cards(:text).considering?
assert_equal "doing", cards(:logo).engagement_status
assert_equal "considering", cards(:text).engagement_status
end
test "change the engagement" do
@@ -46,21 +49,4 @@ class Card::EngageableTest < ActiveSupport::TestCase
assert_includes Card.considering, cards(:text)
assert_not_includes Card.considering, cards(:logo)
end
test "auto_reconsider_all_stagnated" do
travel_to Time.current
cards(:logo, :shipping).each(&:engage)
cards(:logo).update!(last_active_at: 1.day.ago - Card::Engageable::STAGNATED_AFTER)
cards(:shipping).update!(last_active_at: 1.day.from_now - Card::Engageable::STAGNATED_AFTER)
assert_difference -> { Card.considering.count }, +1 do
Card.auto_reconsider_all_stagnated
end
assert cards(:shipping).reload.doing?
assert cards(:logo).reload.considering?
assert_equal Time.current, cards(:logo).last_active_at
end
end
+50
View File
@@ -0,0 +1,50 @@
require "test_helper"
class Card::EntropyTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "auto close all due" do
cards(:logo, :shipping).each(&:reconsider)
cards(:logo).update!(last_active_at: 1.day.ago - collections(:writebook).auto_close_period)
cards(:shipping).update!(last_active_at: 1.day.from_now - collections(:writebook).auto_close_period)
assert_difference -> { Card.closed.count }, +1 do
Card.auto_close_all_due
end
assert cards(:logo).reload.closed?
assert_not cards(:shipping).reload.closed?
end
test "don't auto close those cards where the collection has no auto close period" do
cards(:logo, :shipping).each(&:reconsider)
collections(:writebook).update auto_close_period: nil
assert_no_difference -> { Card.closed.count } do
Card.auto_close_all_due
end
assert_not cards(:logo).reload.closed?
end
test "auto_reconsider_all_stagnated" do
travel_to Time.current
cards(:logo, :shipping).each(&:engage)
cards(:logo).update!(last_active_at: 1.day.ago - Card::AUTO_RECONSIDER_PERIOD)
cards(:shipping).update!(last_active_at: 1.day.from_now - Card::AUTO_RECONSIDER_PERIOD)
assert_difference -> { Card.considering.count }, +1 do
Card.auto_reconsider_all_stagnated
end
assert cards(:shipping).reload.doing?
assert cards(:logo).reload.considering?
assert_equal Time.current, cards(:logo).last_active_at
end
end