@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/**/*'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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." }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,4 +1,5 @@
|
||||
class Access < ApplicationRecord
|
||||
belongs_to :account, default: -> { user.account }
|
||||
belongs_to :board, touch: true
|
||||
belongs_to :user, touch: true
|
||||
|
||||
|
||||
+15
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module Ai
|
||||
def self.table_name_prefix
|
||||
"ai_"
|
||||
end
|
||||
end
|
||||
@@ -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,4 +1,5 @@
|
||||
class Assignment < ApplicationRecord
|
||||
belongs_to :account, default: -> { card.account }
|
||||
belongs_to :card, touch: true
|
||||
|
||||
belongs_to :assignee, class_name: "User"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
class Board::Publication < ApplicationRecord
|
||||
belongs_to :account, default: -> { board.account }
|
||||
belongs_to :board
|
||||
|
||||
has_secure_token :key
|
||||
|
||||
+17
-3
@@ -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,3 +1,4 @@
|
||||
class Card::ActivitySpike < ApplicationRecord
|
||||
belongs_to :account, default: -> { card.account }
|
||||
belongs_to :card, touch: true
|
||||
end
|
||||
|
||||
@@ -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,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] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
class Card::Goldness < ApplicationRecord
|
||||
belongs_to :account, default: -> { card.account }
|
||||
belongs_to :card, touch: true
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user