Merge pull request #585 from basecamp/entropy-settings-ui

Entropy improvements
This commit is contained in:
Jorge Manrubia
2025-06-05 09:19:56 +02:00
committed by GitHub
53 changed files with 806 additions and 280 deletions
+1
View File
@@ -35,3 +35,4 @@
/config/master.key
/config/credentials/*.key
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
@layer components {
.divider {
--divider-padding: var(--inline-space);
--divider-color: var(--color-ink-light);
align-items: center;
display: flex;
margin-inline: calc(var(--divider-padding) * -1);
&:before,
&:after {
background: var(--divider-color);
block-size: var(--divider-size, 1px);
content: "";
flex: 1;
margin: 0 var(--divider-padding);
}
}
}
+142
View File
@@ -0,0 +1,142 @@
@layer components {
.knob {
--knob-angle-reserve: 120deg;
--knob-option-angle: calc((360deg - var(--knob-angle-reserve)) / (var(--knob-options) - 1));
--knob-option-size: 3ch;
--knob-chamfer-size: 1ch;
--knob-color: oklch(var(--lch-ink-light));
--knob-color-accent: oklch(var(--lch-blue-medium));
--knob-tick-size: 1ch;
--knob-radius: calc(var(--knob-size) / 2);
--knob-size: 96px;
border: none;
display: block;
font-weight: 500;
padding: var(--knob-option-size) 0 0;
position: relative;
text-align: center;
}
.knob__slider {
--hover-size: 0;
appearance: none;
background-color: transparent;
block-size: var(--knob-size);
inline-size: var(--knob-size);
inset: 50% auto auto 50%;
position: absolute;
translate: -50% -50%;
z-index: 1;
&::-webkit-slider-runnable-track {
block-size: var(--knob-size);
cursor: grab;
}
&::-webkit-slider-thumb {
appearance: none;
background-color: transparent;
height: 1px;
width: 1px;
}
}
.knob__option {
block-size: var(--knob-option-size);
border-radius: 50%;
cursor: pointer;
display: grid;
inline-size: var(--knob-option-size);
inset: 50% auto auto 50%;
place-content: center;
position: absolute;
transform:
translate(-50%, -50%)
rotate(calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--i))))
translateY(calc(-1 * var(--knob-radius) - 50% - var(--knob-tick-size)));
z-index: 1;
&:hover,
&:has(input:checked) {
color: var(--knob-color-accent);
}
&:has(:focus-visible) {
box-shadow: 0 0 0 2px var(--knob-color);
}
/* Tick marks */
&:before {
background-color: var(--knob-color);
block-size: var(--knob-tick-size);
content: "";
inline-size: 2px;
inset: 100% auto auto 50%;
position: absolute;
translate: -50% 0;
}
/* The value text */
span {
rotate: calc(((360deg - var(--knob-angle-reserve)) / 2) - (var(--knob-option-angle) * var(--i)));
}
input {
opacity: 0;
position: absolute;
}
}
.knob__knob {
background: linear-gradient(to top, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));
block-size: var(--knob-size);
border-radius: 50%;
box-shadow:
0 0 2px 1px rgba(0,0,0,0.10),
0 2px 4px rgba(0,0,0,0.15),
0 2px 8px rgba(0,0,0,0.20);
inline-size: var(--knob-size);
margin-inline: auto;
position: relative;
&:before,
&:after {
content: "";
position: absolute;
}
/* Indent */
&:before {
background: linear-gradient(to bottom, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));
block-size: calc(var(--knob-size) - var(--knob-chamfer-size));
border-radius: 50%;
box-shadow:
0 -1px 0 rgba(255, 255, 255, 0.25),
inset 0 -1px 0 rgba(255, 255, 255, 0.25);
inline-size: calc(var(--knob-size) - var(--knob-chamfer-size));
inset: 50% auto auto 50%;
translate: -50% -50%;
}
/* Indicator */
&:after {
background-color: var(--color-ink);
block-size: calc(var(--knob-radius) - var(--knob-chamfer-size) / 2);
border-radius: 50% 50% 2px 2px;
inline-size: 4px;
inset: auto auto 50% 50%;
rotate: calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--knob-index)));
transform-origin: center bottom;
transition: rotate 100ms;
translate: -50% 0;
}
}
.knob__label {
font-weight: bold;
margin-block-start: 1ch;
text-transform: uppercase;
}
}
@@ -0,0 +1,12 @@
class Accounts::EntropyConfigurationsController < ApplicationController
def update
Entropy::Configuration.default.update!(entropy_configuration_params)
redirect_to account_settings_path, notice: "Account updated"
end
private
def entropy_configuration_params
params.expect(entropy_configuration: [ :auto_close_period, :auto_reconsider_period ])
end
end
@@ -0,0 +1,6 @@
class Accounts::SettingsController < ApplicationController
def show
@account = Account.sole
@users = User.active
end
end
+1 -1
View File
@@ -33,7 +33,7 @@ class CollectionsController < ApplicationController
end
def collection_params
params.expect(collection: [ :name, :all_access, :auto_close_period ])
params.expect(collection: [ :name, :all_access, :auto_close_period, :auto_reconsider_period ])
end
def grantees
@@ -1,12 +0,0 @@
module UserTimelineScoped
extend ActiveSupport::Concern
included do
include FilterScoped
end
def show
@filter = Current.user.filters.new(creator_ids: [ @user.id ])
@day_timeline = Current.user.timeline_for(Time.current, filter: @filter)
end
end
+5 -5
View File
@@ -1,15 +1,10 @@
class UsersController < ApplicationController
include UserTimelineScoped
require_unauthenticated_access only: %i[ new create ]
before_action :set_user, only: %i[ show edit update destroy ]
before_action :ensure_join_code_is_valid, only: %i[ new create ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
def index
@users = User.active
end
def new
@user = User.new
end
@@ -23,6 +18,11 @@ class UsersController < ApplicationController
def edit
end
def show
@filter = Current.user.filters.new(creator_ids: [ @user.id ])
@day_timeline = Current.user.timeline_for(Time.current, filter: @filter)
end
def update
@user.update! user_params
redirect_to @user
+4
View File
@@ -56,4 +56,8 @@ module CardsHelper
"Closes"
end
end
def cacheable_preview_parts_for(card)
[ card, card.collection.workflow, Entropy::Configuration.default ]
end
end
-23
View File
@@ -1,23 +0,0 @@
module CollectionsHelper
def collection_auto_close_options
[
[ "3 days", 3.days ],
[ "7 days", 7.days ],
[ "30 days", 30.days ],
[ "90 days", 90.days ],
[ "365 days", 365.days ]
]
end
def collection_stalled_options
[
[ "1 day", 1.days ],
[ "2 days", 2.days ],
[ "3 days", 3.days ],
[ "7 days", 7.days ],
[ "14 days", 14.days ],
[ "30 days", 30.days ],
[ "365 days", 365.days ]
]
end
end
+5
View File
@@ -0,0 +1,5 @@
module EntropyHelper
def entropy_auto_close_options
[ 3, 7, 11, 30, 90, 365 ]
end
end
@@ -0,0 +1,38 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "field", "option", "slider" ]
connect() {
this.#index = this.#selectedOption.dataset.index
}
optionChanged({ target }) {
this.#index = target.dataset.index
}
sliderChanged({ target }) {
this.#index = target.value
}
set #index(index) {
this.fieldTarget.style.setProperty("--knob-index", `${index}`);
this.sliderTarget.value = index
}
get #selectedOption() {
return this.optionTargets.find(option => {
return option.checked
})
}
set #value(index) {
this.#optionForIndex(index).checked = true
}
#optionForIndex(index) {
return this.optionTargets.find(option => {
return option.dataset.index === index;
})
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Joinable
include Entropic, Joinable
has_many_attached :uploads
end
+18
View File
@@ -0,0 +1,18 @@
module Account::Entropic
extend ActiveSupport::Concern
included do
has_one :default_entropy_configuration, class_name: "Entropy::Configuration", as: :container, dependent: :destroy
before_save :set_default_entropy_configuration
end
private
DEFAULT_ENTROPY_PERIOD = 30.days
def set_default_entropy_configuration
self.default_entropy_configuration ||= build_default_entropy_configuration \
auto_close_period: DEFAULT_ENTROPY_PERIOD,
auto_reconsider_period: DEFAULT_ENTROPY_PERIOD
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Card < ApplicationRecord
include Assignable, Colored, Engageable, Entropy, Eventable,
include Assignable, Colored, Engageable, Entropic, Eventable,
Golden, Mentions, Pinnable, Closeable, Readable, Searchable,
Staged, Statuses, Taggable, Watchable
+37
View File
@@ -0,0 +1,37 @@
module Card::Entropic
extend ActiveSupport::Concern
ENTROPY_REMINDER_BEFORE = 7.days
included do
scope :entropic_by, ->(period_name) do
left_outer_joins(collection: :entropy_configuration)
.where("last_active_at <= DATETIME('now', '-' || COALESCE(entropy_configurations.#{period_name}, ?) || ' seconds')",
Entropy::Configuration.default.public_send(period_name))
end
scope :stagnated, -> { doing.entropic_by(:auto_reconsider_period) }
scope :due_to_be_closed, -> { considering.entropic_by(:auto_close_period) }
delegate :auto_close_period, :auto_reconsider_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 entropy
Card::Entropy.for(self)
end
def entropic?
entropy.present?
end
end
+14 -49
View File
@@ -1,59 +1,24 @@
module Card::Entropy
extend ActiveSupport::Concern
class Card::Entropy
attr_reader :card, :auto_clean_period
AUTO_RECONSIDER_PERIOD = 30.days
ENTROPY_REMINDER_BEFORE = 7.days
class << self
def for(card)
return unless card.last_active_at
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_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")
if card.considering?
new(card, card.auto_close_period)
elsif card.doing?
new(card, card.auto_reconsider_period)
end
end
def auto_reconsider_all_stagnated
stagnated.find_each(&:reconsider)
end
end
def subject_to_entropy?
auto_reconsidering? || auto_closing?
def initialize(card, auto_clean_period)
@card = card
@auto_clean_period = auto_clean_period
end
def auto_reconsidering?
doing? && last_active_at
end
def auto_closing?
considering? && collection.auto_closing? && last_active_at
end
def auto_close_at
last_active_at + auto_close_period if auto_closing?
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 auto_reconsidering?
end
def days_until_reconsider
(auto_reconsider_at.to_date - Date.current).to_i if auto_reconsider_at
end
def entropy_cleaned_at
auto_close_at || auto_reconsider_at
def auto_clean_at
card.last_active_at + auto_clean_period
end
end
+2 -1
View File
@@ -1,11 +1,12 @@
class Collection < ApplicationRecord
include AutoClosing, Accessible, Broadcastable, Filterable, Workflowing
include AutoClosing, Accessible, Broadcastable, Entropic, Filterable, Workflowing
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :cards, dependent: :destroy
has_many :tags, -> { distinct }, through: :cards
has_many :events
has_one :entropy_configuration, class_name: "Entropy::Configuration", as: :container, dependent: :destroy
scope :alphabetically, -> { order("lower(name)") }
-5
View File
@@ -2,14 +2,9 @@ module Collection::AutoClosing
extend ActiveSupport::Concern
included do
scope :auto_closing, -> { where.not(auto_close_period: nil) }
before_create :set_default_auto_close_period
end
def auto_closing?
auto_close_period.present?
end
private
DEFAULT_AUTO_CLOSE_PERIOD = 30.days
+21
View File
@@ -0,0 +1,21 @@
module Collection::Entropic
extend ActiveSupport::Concern
included do
delegate :auto_close_period, :auto_reconsider_period, to: :entropy_configuration
end
def entropy_configuration
super || Entropy::Configuration.default
end
def auto_close_period=(new_value)
entropy_configuration ||= association(:entropy_configuration).reader || self.build_entropy_configuration
entropy_configuration.update auto_close_period: new_value
end
def auto_reconsider_period=(new_value)
entropy_configuration ||= association(:entropy_configuration).reader || self.build_entropy_configuration
entropy_configuration.update auto_reconsider_period: new_value
end
end
+5
View File
@@ -0,0 +1,5 @@
module Entropy
def self.table_name_prefix
"entropy_"
end
end
+9
View File
@@ -0,0 +1,9 @@
class Entropy::Configuration < ApplicationRecord
belongs_to :container, polymorphic: true
class << self
def default
Account.sole.default_entropy_configuration
end
end
end
@@ -1,4 +1,4 @@
<div class="flex flex-column align-center gap">
<div class="flex flex-column align-center fill-shade pad border-radius gap">
<% url = join_url(Account.sole.join_code) %>
<label class="flex flex-column gap full-width txt-align-center">
@@ -0,0 +1,5 @@
<div class="panel shadow center flex flex-column">
<strong class="divider txt-large">Do or Die</strong>
<p class="margin-none-block-start">Choose default settings for this account. You can override them in individual collections.</p>
<%= render "entropy/auto_close", model: account.default_entropy_configuration, url: account_entropy_configuration_path %>
</div>
@@ -0,0 +1,7 @@
<div class="panel shadow center flex flex-column">
<strong class="divider txt-large margin-block-end">People on the account</strong>
<%= render "accounts/invite" %>
<div class="flex flex-column gap margin-block-start">
<%= render partial: "accounts/user", collection: users %>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
<% @page_title = "Account Settings" %>
<% content_for :header do %>
<nav>
<%= link_to_back fallback_path: root_path %>
<header class="center">
<h1 class="txt-large margin-none"><%= @page_title %></h1>
</header>
<%= link_to workflows_path, class: "btn flex-item-justify-end" do %>
<%= icon_tag "bolt" %>
<span class="for-screen-reader">Workflows</span>
<% end %>
</nav>
<% end %>
<section class="panels--two-up margin-block">
<%= render "accounts/settings/users", users: @users %>
<%= render "accounts/settings/entropy_configuration", account: @account %>
</section>
+6 -4
View File
@@ -22,10 +22,12 @@
</details>
<span class="card-perma__closure-message">
<% if card.doing? %>
Falls back to <em>Considering</em> if no activity <%= local_datetime_tag(card.auto_reconsider_at, style: :indays) -%>.
<% elsif card.auto_closing? %>
Auto-closes if no activity <%= local_datetime_tag(card.auto_close_at, style: :indays) -%>.
<% if card.entropic? %>
<% if card.doing? %>
Falls back to <em>Considering</em> if no activity <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%>.
<% else %>
Auto-closes if no activity <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%>.
<% end %>
<% end %>
</span>
<% end %>
+2 -2
View File
@@ -1,4 +1,4 @@
<% cache [ card, card.collection.workflow ] do %>
<% cache cacheable_preview_parts_for(card) do %>
<%= card_article_tag card, class: "card" do %>
<div class="flex gap">
<div class="flex flex-column flex-item-grow">
@@ -29,7 +29,7 @@
<%= render "cards/display/common/background", card: card %>
<% if card.subject_to_entropy? %>
<% if card.entropic? %>
<%= render "cards/display/preview/bubble", card: card %>
<% end %>
<% end %>
@@ -1,7 +1,7 @@
<div hidden class="bubble bubble--<%= card.engagement_status %>"
data-controller="bubble" data-action="turbo:morph-element->bubble#update"
data-bubble-reminder-before-value="<%= Card::ENTROPY_REMINDER_BEFORE.in_days.to_i %>"
data-bubble-closes-at-value="<%= card.entropy_cleaned_at.iso8601 %>"
data-bubble-reminder-before-value="<%= Card::Entropic::ENTROPY_REMINDER_BEFORE.in_days.to_i %>"
data-bubble-closes-at-value="<%= card.entropy.auto_clean_at.iso8601 %>"
data-bubble-entropy-action-value="<%= card_entropy_action(card) %>">
<svg viewBox="0 0 200 100">
<path id="top-half" fill="transparent" d="M 20,100 A 80,80 0 0,1 180,100" />
@@ -8,7 +8,7 @@
<% end %>
<% if page.used? %>
<%= render partial: "cards/display/preview", collection: page.records, as: :card, cached: ->(record) { [ record, record.collection.workflow ] } %>
<%= render partial: "cards/display/preview", collection: page.records, as: :card, cached: ->(card) { cacheable_preview_parts_for(card) } %>
<% unless page.last? %>
<%= cards_next_page_link "doing-cards", page: page, filter: filter %>
+1 -3
View File
@@ -32,11 +32,9 @@
<div class="panel shadow center flex flex-column">
<%= render "collections/edit/workflows", collection: @collection %>
<%= render "collections/edit/auto_close", collection: @collection %>
<%= render "collections/edit/stalled", collection: @collection %>
<hr class="separator--horizontal full-width margin-block" style="--border-color: var(--color-ink-medium);" aria-hidden="true" />
<hr class="separator--horizontal full-width margin-block-end margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true" />
<%= form_with model: @collection, class: "txt-align-center", method: :delete do |form| %>
<%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this Collection?" } do %>
@@ -1,6 +1,4 @@
<strong class="txt-large">Do or Die</strong>
<label for="closure" class="margin-block-end">Cards automatically close when there is no activity for…</label>
<%= form_with model: collection, data: { controller: "form" } do |form| %>
<%= form.select :auto_close_period, collection_auto_close_options, {}, { class: "input input--select full-width margin-block-end", data: { action: "change->form#submit" } } %>
<% end %>
<div class="margin-block-end">
<strong class="divider txt-large">Do or Die</strong>
<%= render "entropy/auto_close", model: collection %>
</div>
+35
View File
@@ -0,0 +1,35 @@
<% name = label.parameterize %>
<fieldset class="knob" data-controller="knob" data-knob-target="field" style="--knob-options: <%= knob_options.length %>; --knob-index: <%= default_index %>;">
<div class="position-relative" role="radiogroup" aria-labelledby="<%= name %>">
<% knob_options.each_with_index do |value, i| %>
<label class="knob__option" style="--i: <%= i %>">
<input
<%= "checked" if value == knob_options[default_index] %>
data-action="change->knob#optionChanged"
data-index="<%= i %>"
data-knob-target="option"
name="<%= name %>"
type="radio"
value="<%= value %>"
/>
<span><%= value %></span>
</label>
<% end %>
<input
aria-hidden
class="knob__slider"
data-action="input->knob#sliderChanged"
data-knob-target="slider"
max="<%= knob_options.length - 1 %>"
min="0"
type="range"
value="<%= default_index %>"
/>
<div class="knob__knob" aria-hidden></div>
</div>
<div id="<%= name %>" class="knob__label">Days</div>
</fieldset>
@@ -1,6 +0,0 @@
<strong class="txt-large">Stalled</strong>
<label for="closure" class="margin-block-end">Mark cards stalled when there is no activity for…</label>
<%= form_with model: collection, data: { controller: "form" } do |form| %>
<%= form.select :stalled_period, collection_stalled_options, {}, { class: "input input--select full-width margin-block-end", data: { action: "change->form#submit" } } %>
<% end %>
+29
View File
@@ -0,0 +1,29 @@
<% url = local_assigns[:url] %>
<div class="flex align-start gap">
<div class="flex flex-column full-width" style="flex-basis: 48%">
<strong>Cards in Considering</strong>
<p class="txt-x-small margin-none-block-start">Auto-close after…</p>
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
form: form,
name: :auto_close_period,
knob_options: entropy_auto_close_options,
label: "Days until auto-close" %>
<% end %>
</div>
<div class="separator margin-block-half" style="--border-color: var(--color-ink-light); min-block-size: 3lh;"></div>
<div class="flex flex-column full-width" style="flex-basis: 48%">
<strong>Cards in Doing</strong>
<p class="txt-x-small margin-none-block-start">Fall back to Considering after…</p>
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
form: form,
name: :auto_reconsider_period,
knob_options: entropy_auto_close_options,
label: "Days until auto-close" %>
<% end %>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<%
period = form.object.send(name)
current_index = knob_options.index(period.to_i / 1.day)
%>
<fieldset class="knob" data-controller="knob" data-knob-target="field" style="--knob-options: <%= knob_options.length %>; --knob-index: <%= current_index %>">
<div class="position-relative" role="radiogroup" aria-labelledby="<%= dom_id(form.object, name) %>">
<% knob_options.each_with_index do |value, index| %>
<label class="knob__option" style="--i: <%= index %>">
<%= form.radio_button name,
value.days,
data: {
action: "change->knob#optionChanged change->form#submit",
index: index,
knob_target: "option"
} %>
<span><%= value %></span>
</label>
<% end %>
<%= form.range_field :slider,
class: "knob__slider",
data: { action: "input->knob#sliderChanged change->form#submit", knob_target: "slider" },
"aria-hidden": true,
max: knob_options.length - 1,
min: 0
%>
<div class="knob__knob" aria-hidden></div>
</div>
<div id="<%= dom_id(form.object, name) %>" class="knob__label">Days</div>
</fieldset>
+1 -1
View File
@@ -22,7 +22,7 @@
<%= render "events/filter", filter: @filter %>
</div>
<%= link_to users_path, class: "btn flex-item-justify-end" do %>
<%= link_to account_settings_path, class: "btn flex-item-justify-end" do %>
<%= icon_tag "settings" %>
<span class="for-screen-reader">Account settings</span>
<% end %>
-24
View File
@@ -1,24 +0,0 @@
<% @page_title = "People on the account" %>
<% content_for :header do %>
<nav class="align-start">
<%= link_to_home icon: "arrow-left", label: "Go back", class: "flex-item-justify-start" %>
<header class="flex flex-column center">
<h1 class="txt-x-large"><%= @page_title %></h1>
</header>
<%= link_to workflows_path, class: "btn flex-item-justify-end" do %>
<%= icon_tag "bolt" %>
<span class="for-screen-reader">Workflows</span>
<% end %>
</nav>
<% end %>
<div class="panel borderless center pad fill-shade flex flex-column margin-block">
<%= render "users/invite" %>
</div>
<div class="panel borderless center pad fill-none flex flex-column gap">
<%= render @users %>
</div>
+24 -25
View File
@@ -1,5 +1,28 @@
{
"ignored_warnings": [
{
"warning_type": "SQL Injection",
"warning_code": 0,
"fingerprint": "1261c0a89b23c1d7386d684f41c26cc230fa9b5b00d062d7f8d4d1a15bde96a8",
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/card/entropic.rb",
"line": 9,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "left_outer_joins(:collection => :entropy_configuration).where(\"last_active_at <= DATETIME('now', '-' || COALESCE(entropy_configurations.#{period_name}, ?) || ' seconds')\", Entropy::Configuration.default.public_send(period_name))",
"render_path": null,
"location": {
"type": "method",
"class": "Card::Entropic",
"method": null
},
"user_input": "period_name",
"confidence": "Weak",
"cwe_id": [
89
],
"note": ""
},
{
"warning_type": "SQL Injection",
"warning_code": 0,
@@ -7,7 +30,7 @@
"check_name": "SQL",
"message": "Possible SQL injection",
"file": "app/models/concerns/searchable.rb",
"line": 14,
"line": 22,
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
"code": "joins(\"join #{using} idx on #{table_name}.id = idx.rowid\")",
"render_path": null,
@@ -23,30 +46,6 @@
],
"note": ""
},
{
"warning_type": "Dangerous Eval",
"warning_code": 13,
"fingerprint": "971ca740ceee7ae9a7d02a257964afd0b80672b3a4c49eeaf8a07a178bb84e78",
"check_name": "Evaluation",
"message": "Dynamic string evaluated as code",
"file": "lib/rails_ext/action_text_has_markdown.rb",
"line": 7,
"link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
"code": "class_eval(\" def #{name}\\n markdown_#{name} || build_markdown_#{name}\\n end\\n\\n def #{name}_html\\n #{name}.to_html\\n end\\n\\n def #{name}_plain_text\\n #{name}.to_plain_text\\n end\\n\\n def #{name}?\\n markdown_#{name}.present?\\n end\\n\\n def #{name}=(content)\\n self.#{name}.content = content\\n end\\n\", \"lib/rails_ext/action_text_has_markdown.rb\", 8)",
"render_path": null,
"location": {
"type": "method",
"class": "ActionText::HasMarkdown",
"method": "has_markdown"
},
"user_input": null,
"confidence": "Weak",
"cwe_id": [
913,
95
],
"note": ""
},
{
"warning_type": "Remote Code Execution",
"warning_code": 24,
+5
View File
@@ -3,6 +3,11 @@ Rails.application.routes.draw do
resource :account do
resource :join_code, module: :accounts
scope module: :accounts do
resource :settings
resource :entropy_configuration
end
end
resources :users do
@@ -0,0 +1,15 @@
class CreateEntropyConfigurations < ActiveRecord::Migration[8.1]
def change
create_table :entropy_configurations do |t|
t.references :container, polymorphic: true, null: false, index: { unique: true }
t.bigint :auto_close_period, null: false, default: 30.days.to_i
t.bigint :auto_reconsider_period, null: false, default: 30.days.to_i
t.timestamps
t.index %i[ container_type container_id auto_close_period ]
t.index %i[ container_type container_id auto_reconsider_period ]
end
end
end
@@ -0,0 +1,5 @@
class RemoveAutoClosePeriodFromCollections < ActiveRecord::Migration[8.1]
def change
remove_column :collections, :auto_close_period
end
end
Generated
+13 -3
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_06_01_161653) do
ActiveRecord::Schema[8.1].define(version: 2025_06_04_080022) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -141,13 +141,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_01_161653) do
create_table "collections", force: :cascade do |t|
t.boolean "all_access", default: false, null: false
t.bigint "auto_close_period"
t.datetime "created_at", null: false
t.integer "creator_id", null: false
t.string "name", null: false
t.datetime "updated_at", null: false
t.integer "workflow_id"
t.index ["auto_close_period"], name: "index_collections_on_auto_close_period"
t.index ["creator_id"], name: "index_collections_on_creator_id"
t.index ["workflow_id"], name: "index_collections_on_workflow_id"
end
@@ -188,6 +186,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_01_161653) do
t.index ["filter_id"], name: "index_creators_filters_on_filter_id"
end
create_table "entropy_configurations", force: :cascade do |t|
t.bigint "auto_close_period", default: 2592000, null: false
t.bigint "auto_reconsider_period", default: 2592000, null: false
t.integer "container_id", null: false
t.string "container_type", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["container_type", "container_id", "auto_close_period"], name: "idx_on_container_type_container_id_auto_close_perio_74dc880875"
t.index ["container_type", "container_id", "auto_reconsider_period"], name: "idx_on_container_type_container_id_auto_reconsider__583aaddbea"
t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true
end
create_table "events", force: :cascade do |t|
t.string "action", null: false
t.integer "collection_id", null: false
+101 -27
View File
@@ -495,16 +495,6 @@ columns:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auto_close_period
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
collation:
comment:
- *5
- *24
- *6
@@ -585,6 +575,50 @@ columns:
creators_filters:
- *24
- *19
entropy_configurations:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auto_close_period
cast_type: *11
sql_type_metadata: *12
'null': false
default: 2592000
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auto_reconsider_period
cast_type: *11
sql_type_metadata: *12
'null': false
default: 2592000
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: container_id
cast_type: *1
sql_type_metadata: *2
'null': false
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: container_type
cast_type: *7
sql_type_metadata: *8
'null': false
default:
default_function:
collation:
comment:
- *5
- *6
- *9
events:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -997,6 +1031,7 @@ primary_keys:
commands: id
comments: id
creators_filters:
entropy_configurations: id
events: id
filters: id
filters_stages:
@@ -1035,6 +1070,7 @@ data_sources:
commands: true
comments: true
creators_filters: true
entropy_configurations: true
events: true
filters: true
filters_stages: true
@@ -1447,22 +1483,6 @@ indexes:
comment:
valid: true
collections:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: collections
name: index_collections_on_auto_close_period
unique: false
columns:
- auto_close_period
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: collections
name: index_collections_on_creator_id
@@ -1646,6 +1666,60 @@ indexes:
nulls_not_distinct:
comment:
valid: true
entropy_configurations:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: entropy_configurations
name: idx_on_container_type_container_id_auto_close_perio_74dc880875
unique: false
columns:
- container_type
- container_id
- auto_close_period
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: entropy_configurations
name: idx_on_container_type_container_id_auto_reconsider__583aaddbea
unique: false
columns:
- container_type
- container_id
- auto_reconsider_period
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: entropy_configurations
name: index_entropy_configurations_on_container
unique: true
columns:
- container_type
- container_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
events:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: events
@@ -2137,4 +2211,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250601161653
version: 20250604080022
@@ -0,0 +1,16 @@
require "test_helper"
class Accounts::EntropyConfigurationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "update" do
put account_entropy_configuration_path, params: { entropy_configuration: { auto_close_period: 1.day, auto_reconsider_period: 2.days } }
assert_equal 1.day, entropy_configurations("37s_account").auto_close_period
assert_equal 2.days, entropy_configurations("37s_account").auto_reconsider_period
assert_redirected_to account_settings_path
end
end
@@ -0,0 +1,12 @@
require "test_helper"
class Accounts::SettingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "show" do
get account_settings_path
assert_response :success
end
end
@@ -30,7 +30,9 @@ class CollectionsControllerTest < ActionDispatch::IntegrationTest
patch collection_path(collections(:writebook)), params: {
collection: {
name: "Writebook bugs",
all_access: false
all_access: false,
auto_close_period: 1.day,
auto_reconsider_period: 2.days
},
user_ids: users(:david, :jz).pluck(:id)
}
@@ -38,6 +40,8 @@ class CollectionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to edit_collection_path(collections(:writebook))
assert_equal "Writebook bugs", collections(:writebook).reload.name
assert_equal users(:david, :jz).sort, collections(:writebook).users.sort
assert_equal 1.day, entropy_configurations(:writebook_collection).auto_close_period
assert_equal 2.days, entropy_configurations(:writebook_collection).auto_reconsider_period
assert_not collections(:writebook).all_access?
end
@@ -1,14 +1,6 @@
require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
test "index" do
sign_in_as :kevin
get users_path
assert_in_body users(:david).name
assert_in_body users(:kevin).name
end
test "new" do
get new_user_path(params: { join_code: "bad" })
assert_response :forbidden
-2
View File
@@ -2,11 +2,9 @@ writebook:
name: Writebook
creator: david
all_access: true
auto_close_period: <%= 30.days.to_i %>
workflow: qa
private:
name: Private collection
creator: kevin
all_access: false
auto_close_period: <%= 30.days.to_i %>
+14
View File
@@ -0,0 +1,14 @@
37s_account:
container: 37s (Account)
auto_reconsider_period: <%= 30.days.to_i %>
auto_close_period: <%= 30.days.to_i %>
writebook_collection:
container: writebook (Collection)
auto_reconsider_period: <%= 90.days.to_i %>
auto_close_period: <%= 90.days.to_i %>
private_collection:
container: private (Collection)
auto_reconsider_period: <%= 30.days.to_i %>
auto_close_period: <%= 30.days.to_i %>
+107
View File
@@ -0,0 +1,107 @@
require "test_helper"
class Card::EntropicTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "auto_close_at uses the period defined in the account by default" do
freeze_time
entropy_configurations(:writebook_collection).destroy
entropy_configurations("37s_account").reload.update! auto_close_period: 456.days
cards(:layout).update! last_active_at: 2.day.ago
assert_equal (456 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
end
test "auto_close_at infers the period from the collection when present" do
freeze_time
entropy_configurations(:writebook_collection).update! auto_close_period: 123.days
cards(:layout).update! last_active_at: 2.day.ago
assert_equal (123 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
end
test "auto_reconsider_at uses the period defined in the account by default" do
freeze_time
cards(:layout).engage
entropy_configurations(:writebook_collection).destroy
entropy_configurations("37s_account").reload.update! auto_reconsider_period: 456.days
cards(:layout).update! last_active_at: 2.day.ago
assert_equal (456 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
end
test "auto_reconsider_at infers the period from the collection when present" do
freeze_time
cards(:layout).engage
entropy_configurations(:writebook_collection).update! auto_reconsider_period: 123.days
cards(:layout).update! last_active_at: 2.day.ago
assert_equal (123 - 2).days.from_now, cards(:layout).entropy.auto_clean_at
end
test "auto close all due using the default account entropy configuration" do
cards(:logo, :shipping).each(&:reconsider)
entropy_configurations(:writebook_collection).destroy
cards(:logo).update!(last_active_at: 1.day.ago - entropy_configurations("37s_account").auto_close_period)
cards(:shipping).update!(last_active_at: 1.day.from_now - entropy_configurations("37s_account").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 "auto close all due using entropy configuration defined at the collection level" do
cards(:logo, :shipping).each(&:reconsider)
cards(:logo).update!(last_active_at: 1.day.ago - entropy_configurations(:writebook_collection).auto_close_period)
cards(:shipping).update!(last_active_at: 1.day.from_now - entropy_configurations(:writebook_collection).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 "auto consider all stagnated using the default account entropy configuration" do
travel_to Time.current
cards(:logo, :shipping).each(&:engage)
entropy_configurations(:writebook_collection).destroy
cards(:logo).update!(last_active_at: 1.day.ago - entropy_configurations("37s_account").auto_close_period)
cards(:shipping).update!(last_active_at: 1.day.from_now - entropy_configurations("37s_account").auto_close_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
test "auto_reconsider_all_stagnated" do
travel_to Time.current
cards(:logo, :shipping).each(&:engage)
cards(:logo).update!(last_active_at: 1.day.ago - entropy_configurations("writebook_collection").auto_close_period)
cards(:shipping).update!(last_active_at: 1.day.from_now - entropy_configurations("writebook_collection").auto_close_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
-66
View File
@@ -1,66 +0,0 @@
require "test_helper"
class Card::EntropyTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "auto_close_at infers the period from the collection" do
freeze_time
collections(:writebook).update! auto_close_period: 123.days
cards(:layout).update! last_active_at: 2.day.ago
assert_equal (123-2).days.from_now, cards(:layout).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
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
test "entropy_cleaned_at returns when the entropy will be cleaned" do
assert_equal cards(:layout).auto_close_at, cards(:layout).entropy_cleaned_at
assert_not_nil cards(:layout).entropy_cleaned_at
assert_equal cards(:logo).auto_reconsider_at, cards(:logo).entropy_cleaned_at
assert_not_nil cards(:logo).entropy_cleaned_at
end
end