Merge branch 'main' into mobile/prepare-webviews-2

* main: (34 commits)
  Disable card column buttons when active
  Prevent comment content from overlapping with reaction button
  Delete pins belonging to cards in a board with revoked access
  Update development dependencies in SAAS lockfile
  Update runtime dependencies
  Update development dependencies
  Balance magic link instructions
  Add `.txt-balance` utility
  Move handleDesktop inside restoreColumnsDisablingTransitions
  Adjust border radius
  Clean up blank slates
  Add dialog manager to events page
  More sturated mini bubbles in light mode
  Brighten mini bubbles in dark mode
  Use separate cache namespaces for each beta instance
  Bump boost size up a tick on mobile
  Move Undo form inside closure message element
  Phrasing tweak for exceeding storage limit
  Keep strong tags words on same line
  Allow ActionText embed reuse and safe purge (#2346)
  ...
This commit is contained in:
Adrien Maston
2026-01-14 10:36:04 +01:00
97 changed files with 1263 additions and 186 deletions
+31 -32
View File
@@ -123,8 +123,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -132,28 +132,28 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
bootsnap (1.20.1)
msgpack (~> 1.2)
brakeman (7.1.1)
brakeman (7.1.2)
racc
builder (3.3.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -168,23 +168,23 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
debug (1.11.0)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.8)
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faker (3.5.2)
faker (3.5.3)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -224,7 +224,7 @@ GEM
json (2.18.0)
jwt (3.1.2)
base64
kamal (2.9.0)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -271,11 +271,11 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
mocha (2.8.2)
mocha (3.0.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
date
net-protocol
@@ -307,7 +307,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
openssl (3.3.2)
openssl (4.0.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -319,7 +319,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
prism (1.8.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -360,11 +360,11 @@ GEM
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rouge (4.6.1)
rqrcode (3.1.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rqrcode_core (2.1.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -400,7 +400,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.38.0)
selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -430,7 +430,7 @@ GEM
sqlite3 (2.8.0-x86_64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
@@ -440,7 +440,7 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.4.0)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
@@ -458,16 +458,15 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
vcr (6.3.1)
base64
vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.0.2)
web-push (3.1.0)
jwt (~> 3.0)
openssl (~> 3.0)
openssl (>= 3.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
+31 -32
View File
@@ -201,8 +201,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -210,28 +210,28 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
bootsnap (1.20.1)
msgpack (~> 1.2)
brakeman (7.1.1)
brakeman (7.1.2)
racc
builder (3.3.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -246,16 +246,16 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
debug (1.11.0)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.8)
dotenv (3.2.0)
drb (2.2.3)
dry-initializer (3.2.0)
ed25519 (1.4.0)
@@ -263,7 +263,7 @@ GEM
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faker (3.5.2)
faker (3.5.3)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -302,7 +302,7 @@ GEM
json (2.18.0)
jwt (3.1.2)
base64
kamal (2.9.0)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -349,11 +349,11 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
mocha (2.8.2)
mocha (3.0.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
date
net-protocol
@@ -383,7 +383,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
openssl (3.3.2)
openssl (4.0.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -395,7 +395,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
prism (1.8.0)
prometheus-client-mmap (1.4.0)
base64
bigdecimal
@@ -470,11 +470,11 @@ GEM
io-console (~> 0.5)
rexml (3.4.4)
rinku (2.0.6)
rouge (4.6.1)
rqrcode (3.1.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rqrcode_core (2.1.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -511,7 +511,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.38.0)
selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -549,7 +549,7 @@ GEM
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
@@ -560,7 +560,7 @@ GEM
railties (>= 6.0.0)
stringio (3.2.0)
stripe (18.0.1)
thor (1.4.0)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
@@ -577,16 +577,15 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
vcr (6.3.1)
base64
vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.0.2)
web-push (3.1.0)
jwt (~> 3.0)
openssl (~> 3.0)
openssl (>= 3.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
+14 -39
View File
@@ -1,46 +1,21 @@
/* Styles for the blank slate. To manage when they are shown/hidden, do so in context */
@layer components {
.blank-slate--drag {
box-shadow: none !important;
color: color-mix(in srgb, var(--card-color) 70%, var(--color-canvas));
p {
font-size: var(--text-small);
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.cards--maybe & {
background-color: var(--card-bg-color) !important;
}
.cards--grid & {
display: none;
}
}
.blank-slate--empty {
border: 2px dashed var(--color-ink-light);
border-radius: 1ch;
.blank-slate {
border-radius: 0.5ch;
border: 2px dashed var(--color-ink-lighter);
color: var(--color-ink-dark);
margin-block-start: 2dvh;
padding: 1ch 2ch;
rotate: -3deg;
font-weight: 500;
.cards:has(.card) & {
display: none;
}
margin-block-start: 2dvh;
padding: 1.5ch 2ch;
rotate: -3deg;
}
.blank-slate--filters {
.cards--grid:has(.card) & {
display: none;
}
}
.cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty {
display: none;
.blank-slate--drag {
background-color: color-mix(in srgb, transparent, var(--card-color) 5%);
border-color: color-mix(in srgb, transparent, var(--card-color) 10%);
color: color-mix(in srgb, transparent, var(--card-color) 75%);
margin-block-start: 0;
rotate: 0deg;
}
}
+1 -1
View File
@@ -114,7 +114,7 @@
.btn--circle-mobile {
--btn-size: 3em;
--btn-padding: 0;
--icon-size: 70%;
--icon-size: 75%;
aspect-ratio: 1;
inline-size: var(--btn-size);
+47 -3
View File
@@ -255,6 +255,27 @@
}
}
}
&:has(.card) {
.blank-slate {
display: none;
}
}
/* Use the default blank-slate on small viewports since drag-and-drop isn't available */
[data-controller~="drag-and-drop"] & {
@media (max-width: 639px) {
.blank-slate--drag {
display: none;
}
}
@media (min-width: 640px) {
.blank-slate--default {
display: none;
}
}
}
}
.cards__new-column {
@@ -315,6 +336,10 @@
inline-size: fit-content;
margin: 0 0 0 auto;
}
.blank-slate--drag {
display: none;
}
}
/* Column Elements
@@ -731,7 +756,8 @@
.cards--on-deck {
--card-color: var(--color-ink-light) !important;
.card {
.card,
.blank-slate {
--card-color: var(--color-card-complete) !important;
}
@@ -748,12 +774,13 @@
.cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container,
.cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
--bubble-opacity: 75%;
--bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
&:before {
background: radial-gradient(
color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%,
color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100%
color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%,
color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100%
);
block-size: 1em;
border-radius: var(--bubble-shape);
@@ -777,6 +804,22 @@
z-index: -1;
}
}
@media (max-width: 639px) {
&.cards__expander-title:before {
display: none;
}
}
html[data-theme="dark"] & {
--bubble-opacity: 100%;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
--bubble-opacity: 100%;
}
}
}
/* Card column indicators
@@ -806,6 +849,7 @@
--btn-background: var(--card-color);
color: var(--color-ink-inverted);
opacity: 1 !important;
@media (hover: hover) {
&:hover {
+4
View File
@@ -343,5 +343,9 @@
grid-area: closure-message;
margin-block-start: 0.5ch;
padding-inline: 1ch;
form {
display: inline;
}
}
}
+33 -8
View File
@@ -4,7 +4,7 @@
--comment-padding-block: var(--block-space-half);
--comment-padding-inline: var(--inline-space-double);
--comment-max: 70ch;
--reaction-size: 2rem;
--reaction-size: 2.25rem;
display: flex;
flex-direction: column;
@@ -74,10 +74,6 @@
.comments--system-expanded .comment-by-system & {
--stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
}
.comment__history {
background-color: var(--stripe-color);
}
}
.reactions {
@@ -113,6 +109,13 @@
&:not:has(lexxy-editor) {
padding-inline-end: var(--reaction-size);
}
/* Add an empty space so the last line of text doesn't overlap with the reaction button */
.action-text-content > p:last-child::after {
content: "";
display: inline-block;
inline-size: var(--reaction-size);
}
}
.comment__content {
@@ -134,13 +137,35 @@
.comment__edit {
background-color: var(--color-ink-lightest);
&:hover {
z-index: 1;
}
}
.comment__permalink-title {
color: currentColor;
opacity: 0.66;
text-decoration: none;
text-transform: capitalize;
@media (max-width: 639px) {
line-height: 1.5lh;
}
}
.comment__history {
background-color: var(--color-ink-lightest);
background-color: transparent;
display: none;
inset: calc(var(--comment-padding-inline) / 2.5) calc(var(--comment-padding-inline) / 1.5) auto auto; margin-inline-end: calc(var(--comment-padding-inline) / -2.5);
inset: var(--comment-padding-block) var(--comment-padding-block) auto auto;
translate: 2px -2px; /* Align baseline with time stamp */
position: absolute;
@media (any-hover: hover) {
&:hover {
background-color: var(--stripe-color);
}
}
}
.comment-by-system {
@@ -158,7 +183,7 @@
display: contents;
.comment__history {
display: grid;
display: inline-flex;
}
}
+4
View File
@@ -14,5 +14,9 @@
0 0 0 1px color-mix(in oklch, var(--color-golden) 100%, transparent),
0 0 0.2em 0.2em color-mix(in oklch, var(--color-golden) 25%, transparent),
0 0 1em 0.5em color-mix(in oklch, var(--color-golden) 25%, transparent);
lexxy-toolbar {
--lexxy-bg-color: transparent;
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@
}
&:has(.card--notification) {
.notifications-list__empty-message {
.notifications-list__blank-slate {
display: none;
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ summary {
text-align: start;
}
.search__empty {
.search__blank-slate {
margin-block: 3em;
margin-inline: auto;
inline-size: fit-content;
+4 -1
View File
@@ -13,6 +13,7 @@
.txt-align-start { text-align: start; }
.txt-align-end { text-align: end; }
.txt-current { color: currentColor; }
.txt-ink { color: var(--color-ink); }
.txt-reversed { color: var(--color-ink-inverted); }
.txt-negative { color: var(--color-negative); }
@@ -23,14 +24,16 @@
.txt-underline { text-decoration: underline; }
.txt-tight-lines { line-height: 1.2; }
.txt-nowrap { white-space: nowrap; }
.txt-balance { text-wrap: balance; }
.txt-break { word-break: break-word; }
.txt-uppercase { text-transform: uppercase; }
.txt-capitalize { text-transform: capitalize; }
.txt-capitalize-first-letter::first-letter { text-transform: capitalize; }
.txt-link { color: var(--color-link); text-decoration: underline; }
.font-weight-black { font-weight: 900; }
.font-weight-normal { font-weight: normal; }
.font-weight-bold { font-weight: bold; }
.font-weight-black { font-weight: 900; }
/* Flexbox and Grid */
.justify-end { justify-content: end; }
@@ -0,0 +1,13 @@
class Account::CancellationsController < ApplicationController
before_action :ensure_owner
def create
Current.account.cancel
redirect_to session_menu_path(script_name: nil), notice: "Account deleted"
end
private
def ensure_owner
head :forbidden unless Current.user.owner?
end
end
+6 -2
View File
@@ -2,7 +2,7 @@ module Authorization
extend ActiveSupport::Concern
included do
before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? }
before_action :ensure_can_access_account, if: :authenticated_account_access?
end
class_methods do
@@ -25,8 +25,12 @@ module Authorization
head :forbidden unless Current.identity.staff?
end
def authenticated_account_access?
Current.account.present? && authenticated?
end
def ensure_can_access_account
if Current.user.blank? || !Current.user.active?
unless Current.account.active? && Current.user&.active?
respond_to do |format|
format.html { redirect_to session_menu_path(script_name: nil) }
format.json { head :forbidden }
+1 -1
View File
@@ -4,7 +4,7 @@ class My::MenusController < ApplicationController
@boards = Current.user.boards.ordered_by_recently_accessed
@tags = Current.account.tags.all.alphabetically
@users = Current.account.users.active.alphabetically
@accounts = Current.identity.accounts
@accounts = Current.identity.accounts.active
fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ]
end
@@ -1,6 +1,6 @@
class Notifications::UnsubscribesController < ApplicationController
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
skip_forgery_protection
before_action :set_user
@@ -2,6 +2,7 @@ class Public::BaseController < ApplicationController
allow_unauthenticated_access
before_action :set_board, :set_card, :set_public_cache_expiration
before_action :ensure_board_accessible
layout "public"
@@ -17,4 +18,8 @@ class Public::BaseController < ApplicationController
def set_public_cache_expiration
expires_in 30.seconds, public: true
end
def ensure_board_accessible
raise ActionController::RoutingError, "Not Found" if @board&.account&.cancelled?
end
end
+1 -1
View File
@@ -4,7 +4,7 @@ class Sessions::MenusController < ApplicationController
layout "public"
def show
@accounts = Current.identity.accounts
@accounts = Current.identity.accounts.active
if @accounts.one?
redirect_to root_url(script_name: @accounts.first.slug)
+1
View File
@@ -5,6 +5,7 @@ module ColumnsHelper
card_triage_path(card, column_id: column),
method: :post,
class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ],
disabled: column == card.column,
style: "--column-color: #{column.color}",
form_class: "flex gap-half",
data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil }
@@ -14,13 +14,12 @@ export default class extends Controller {
}
async connect() {
await this.#restoreColumnsDisablingTransitions()
this.#setupIntersectionObserver()
this.mediaQuery = window.matchMedia(this.desktopBreakpointValue)
this.handleDesktop = this.#handleDesktop.bind(this)
this.mediaQuery.addEventListener("change", this.handleDesktop)
this.handleDesktop(this.mediaQuery)
await this.#restoreColumnsDisablingTransitions()
this.#setupIntersectionObserver()
}
disconnect() {
@@ -57,6 +56,7 @@ export default class extends Controller {
async #restoreColumnsDisablingTransitions() {
this.#disableTransitions()
this.#restoreColumns()
this.#handleDesktop()
await nextFrame()
this.#enableTransitions()
+14
View File
@@ -0,0 +1,14 @@
class Account::IncinerateDueJob < ApplicationJob
include ActiveJob::Continuable
queue_as :incineration
def perform
step :incineration do |step|
Account.due_for_incineration.find_each do |account|
account.incinerate
step.checkpoint!
end
end
end
end
@@ -1,4 +1,6 @@
class Board::CleanInaccessibleDataJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(user, board)
board.clean_inaccessible_data_for(user)
end
@@ -1,4 +1,6 @@
class Card::ActivitySpike::DetectionJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.detect_activity_spikes
end
@@ -1,4 +1,6 @@
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.remove_inaccessible_notifications
end
+2
View File
@@ -5,6 +5,8 @@ class Event::WebhookDispatchJob < ApplicationJob
queue_as :webhooks
discard_on ActiveJob::DeserializationError
def perform(event)
step :dispatch do |step|
Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook|
+2
View File
@@ -1,6 +1,8 @@
class ExportAccountDataJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform(export)
export.build
end
+2
View File
@@ -1,4 +1,6 @@
class Mention::CreateJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(record, mentioner:)
record.create_mentions(mentioner:)
end
@@ -1,6 +1,8 @@
class Notification::Bundle::DeliverAllJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform
Notification::Bundle.deliver_all
end
@@ -3,6 +3,8 @@ class Notification::Bundle::DeliverJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform(bundle)
bundle.deliver
end
+2
View File
@@ -1,4 +1,6 @@
class NotifyRecipientsJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(notifiable)
notifiable.notify_recipients
end
+2
View File
@@ -1,4 +1,6 @@
class PushNotificationJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(notification)
NotificationPusher.new(notification).push
end
+1
View File
@@ -5,6 +5,7 @@ class Storage::ReconcileJob < ApplicationJob
limits_concurrency to: 1, key: ->(owner) { owner }
discard_on ActiveJob::DeserializationError
retry_on ReconcileAborted, wait: 1.minute, attempts: 3
def perform(owner)
+2
View File
@@ -1,6 +1,8 @@
class Webhook::DeliveryJob < ApplicationJob
queue_as :webhooks
discard_on ActiveJob::DeserializationError
def perform(delivery)
delivery.deliver
end
+11
View File
@@ -0,0 +1,11 @@
class AccountMailer < ApplicationMailer
def cancellation(cancellation)
@account = cancellation.account
@user = cancellation.initiated_by
mail(
to: @user.identity.email_address,
subject: "Your Fizzy account was cancelled"
)
end
end
+2 -2
View File
@@ -1,7 +1,7 @@
class Account < ApplicationRecord
include Account::Storage, Entropic, MultiTenantable, Seedeable
include Account::Storage, Cancellable, Entropic, Incineratable, MultiTenantable, Seedeable
has_one :join_code
has_one :join_code, dependent: :destroy
has_many :users, dependent: :destroy
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
+46
View File
@@ -0,0 +1,46 @@
module Account::Cancellable
extend ActiveSupport::Concern
included do
has_one :cancellation, dependent: :destroy
scope :active, -> { where.missing(:cancellation) }
define_callbacks :cancel
define_callbacks :reactivate
end
def cancel(initiated_by: Current.user)
with_lock do
if cancellable? && active?
run_callbacks :cancel do
create_cancellation!(initiated_by: initiated_by)
end
AccountMailer.cancellation(cancellation).deliver_later
end
end
end
def reactivate
with_lock do
if cancelled?
run_callbacks :reactivate do
cancellation.destroy
end
end
end
end
def active?
!cancelled?
end
def cancelled?
cancellation.present?
end
def cancellable?
Account.accepting_signups?
end
end
+4
View File
@@ -0,0 +1,4 @@
class Account::Cancellation < ApplicationRecord
belongs_to :account
belongs_to :initiated_by, class_name: "User"
end
+17
View File
@@ -0,0 +1,17 @@
module Account::Incineratable
extend ActiveSupport::Concern
INCINERATION_GRACE_PERIOD = 30.days
included do
scope :due_for_incineration, -> { joins(:cancellation).where(account_cancellations: { created_at: ...INCINERATION_GRACE_PERIOD.ago }) }
define_callbacks :incinerate
end
def incinerate
run_callbacks :incinerate do
account.destroy
end
end
end
+5
View File
@@ -46,6 +46,7 @@ module Board::Accessible
mentions_for_user(user).destroy_all
notifications_for_user(user).destroy_all
watches_for(user).destroy_all
pins_for(user).destroy_all
end
def watchers
@@ -99,4 +100,8 @@ module Board::Accessible
def watches_for(user)
Watch.where(card: cards, user: user)
end
def pins_for(user)
Pin.where(card: cards, user: user)
end
end
+1 -1
View File
@@ -74,7 +74,7 @@ class Notification::Bundle < ApplicationRecord
end
def deliverable?
user.settings.bundling_emails? && notifications.any?
user.settings.bundling_emails? && notifications.any? && account.active?
end
def overlapping_bundles
+2 -1
View File
@@ -20,7 +20,8 @@ class NotificationPusher
def should_push?
notification.user.push_subscriptions.any? &&
!notification.creator.system? &&
notification.user.active?
notification.user.active? &&
notification.account.active?
end
def build_payload
+1 -1
View File
@@ -7,6 +7,6 @@ module Webhook::Triggerable
end
def trigger(event)
deliveries.create!(event: event)
deliveries.create!(event: event) unless account.cancelled?
end
end
@@ -0,0 +1,26 @@
<% if Current.account.cancellable? && Current.user.owner? %>
<header class="margin-block-start-double">
<h2 class="divider txt-large txt-negative">Cancel account</h2>
<p class="margin-none-block">Delete your Fizzy account</p>
</header>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account</button>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
<ul class="txt-align-start">
<li>All users, including you, will lose access</li>
<% if Current.account.try(:active_subscription) %>
<li>Your subscription will be canceled</li>
<% end %>
<li>After 30 days your data will be permanently deleted</li>
</ul>
<div class="flex gap justify-center">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
</div>
</dialog>
</div>
<% end %>
+1
View File
@@ -18,6 +18,7 @@
<div class="settings__panel settings__panel--entropy panel shadow center">
<%= render "account/settings/entropy", account: @account %>
<%= render "account/settings/export" %>
<%= render "account/settings/cancellation" %>
</div>
</section>
@@ -1,4 +1,2 @@
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
<div class="blank-slate blank-slate--default">No cards here</div>
<div class="blank-slate blank-slate--drag overflow-ellipsis">Drag cards here</div>
+2 -2
View File
@@ -1,7 +1,7 @@
<%= turbo_frame_tag @board, :entropy do %>
<%= turbo_frame_tag board, :entropy do %>
<div class="margin-block-end">
<h2 class="divider txt-large">Auto close</h2>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…</p>
<%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(@board) %>
<%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(board) %>
</div>
<% end %>
@@ -3,6 +3,6 @@
<%= with_automatic_pagination :filtered_cards_paginated_container, page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
<div class="blank-slate">No cards match this filter</div>
</div>
</section>
+4 -1
View File
@@ -5,6 +5,7 @@
<%= button_to "Not now", card_not_now_path(@card),
class: [ "card__column-name btn", { "card__column-name--current": @card.postponed? } ],
style: "--column-color: var(--color-card-complete)",
disabled: @card.postponed?,
role: "radio",
aria: { checked: @card.postponed? },
data: { scroll_to_target: @card.postponed? ? "target" : nil },
@@ -12,7 +13,8 @@
<%= button_to "Maybe?", card_triage_path(@card), method: :delete,
class: [ "card__column-name card__column-name--stream btn", { "card__column-name--current": @card.awaiting_triage? } ],
style: "--column-color: var(--color-card-default)",
style: "--column-color: var(--color-card-default)",
disabled: @card.awaiting_triage?,
role: "radio",
aria: { checked: @card.awaiting_triage? },
data: { scroll_to_target: @card.awaiting_triage? ? "target" : nil },
@@ -25,6 +27,7 @@
<%= button_to "Done", card_closure_path(@card),
class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ],
style: "--column-color: var(--color-card-complete)",
disabled: @card.closed?,
role: "radio",
aria: { checked: @card.closed? },
data: { scroll_to_target: @card.closed? ? "target" : nil },
+1 -1
View File
@@ -14,7 +14,7 @@
<%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %>
</strong>
<%= link_to comment, class: "txt-undecorated txt-ink translucent txt-normal txt-capitalize", data: { turbo_frame: "_top" } do %>
<%= link_to comment, class: "comment__permalink-title", data: { turbo_frame: "_top" } do %>
<%= local_datetime_tag comment.created_at, style: :agoorweekday %>,
<%= local_datetime_tag comment.created_at, style: :time %>
<% end %>
+6 -4
View File
@@ -2,10 +2,12 @@
<% if card.closed? %>
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="flex gap-half margin-block-start-double margin-block-end">
<span class="card-perma__closure-message">Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>.</span>
<%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
<span class="pad-inline-end">Undo</span>
<% end %>
<span class="card-perma__closure-message">
Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>.
<%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
<span class="pad-inline-end">Undo</span>
<% end %>
</span>
</div>
</div>
<% else %>
+1 -1
View File
@@ -23,7 +23,7 @@
<%= with_automatic_pagination :cards_paginated_container, @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
<div class="blank-slate">No cards match this filter</div>
</div>
</section>
<% end %>
+1 -1
View File
@@ -6,7 +6,7 @@
<% content_for :header do %>
<%= render "events/index/add_card_button", user_filtering: @user_filtering %>
<h1 class="header__title">
<h1 class="header__title" data-controller="dialog-manager">
<% if @user_filtering.boards.many? %>
<span>Activity <%= @user_filtering.filter.boards.any? ? "in" : "across" %></span>
<% else %>
@@ -0,0 +1,9 @@
<%= link_to "https://www.fizzy.do", class: "txt-current font-weight-bold txt-nowrap", target: "_blank", rel: "noopener noreferrer" do %>
<%= icon_tag "fizzy", class: "v-align-middle" %>
Fizzy™
<% end %>
is designed, built, and backed by
<%= link_to "https://37signals.com", class: "txt-current font-weight-bold txt-nowrap", target: "_blank", rel: "noopener noreferrer" do %>
<%= icon_tag "37signals", class: "v-align-middle" %>
37signals™
<% end %>
@@ -0,0 +1,14 @@
<p>Your Fizzy account <strong><%= @account.name %></strong> was cancelled.</p>
<h2>What happens now?</h2>
<ul>
<li>No one can access the account anymore</li>
<% if @account.try(:subscription) %>
<li>We won't charge you anymore</li>
<% end %>
<li>Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %></li>
</ul>
<i>Changed your mind?</i>
<p>If you want to cancel this deletion and restore your account, send us an email to <a href="mailto:support@fizzy.do">support@fizzy.do</a> as soon as possible.</p>
@@ -0,0 +1,13 @@
Your Fizzy account "<%= @account.name %>" was cancelled.
WHAT HAPPENS NOW?
- No one can access the account anymore
<% if @account.try(:subscription) %>
- We won't charge you anymore
<% end %>
- Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %>
CHANGED YOUR MIND?
If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible.
+1 -1
View File
@@ -26,7 +26,7 @@
<%= yield %>
<div class="nav__blank-slate blank-slate blank-slate--empty">
<div class="nav__blank-slate blank-slate">
Nothing matches that filter
</div>
</div>
+1 -1
View File
@@ -9,6 +9,6 @@
<% end %>
<footer class="nav__footer">
<strong class="txt-nowrap"><a href="https://www.fizzy.do" target="_blank" class="txt-ink"><%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™</a></strong> is designed, built, and backed by <strong class="txt-nowrap"><a href="https://37signals.com" target="_blank" class="txt-ink"><%= icon_tag "37signals", class: "v-align-middle" %> 37signals™</a></strong>
<%= render "layouts/shared/colophon" %>
</footer>
<% end %>
@@ -5,7 +5,7 @@
<%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %>
</div>
<% else %>
<div class="notifications-list__empty-message blank-slate blank-slate--empty align-self-center">
<div class="notifications-list__blank-slate blank-slate align-self-center">
Nothing new for you
</div>
<% end %>
+1 -1
View File
@@ -1,3 +1,3 @@
<div class="txt-align-center center margin-block-double txt-subtle txt-small">
<strong class="txt-nowrap"><a href="https://www.fizzy.do" target="_blank" class="txt-subtle"><%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™</a></strong> is designed, built, and backed by <strong class="txt-nowrap"><%= icon_tag "37signals", class: "v-align-middle" %> <a href="https://37signals.com" target="_blank" class="txt-subtle">37signals</a></strong>.
<%= render "layouts/shared/colophon" %>
</div>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
+1 -1
View File
@@ -1,7 +1,7 @@
<section class="search">
<div class="search__results">
<% unless page.used? %>
<div class="search__empty blank-slate blank-slate--empty">
<div class="search__blank-slate blank-slate">
No matches
</div>
<% end %>
+2 -2
View File
@@ -1,4 +1,4 @@
<div class="pad-inline txt-align-center center margin-block-double txt-subtle txt-small">
<strong class="txt-nowrap"><a href="https://www.fizzy.do" target="_blank" class="txt-subtle"><%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™</a></strong> is designed, built, and backed by <strong class="txt-nowrap"><%= icon_tag "37signals", class: "v-align-middle" %> <a href="https://37signals.com" target="_blank" class="txt-subtle">37signals™</a></strong>.
<%= render "layouts/shared/colophon" %>.
Need help? <%= mail_to "support@fizzy.do", "Send us an email", class: "txt-link" %>.
</div>
</div>
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] || flash[:shake] %>">
<header>
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<p class="margin-none-block-start txt-medium">
<p class="margin-none-block-start txt-medium txt-balance">
Then enter the verification code included in the email below:
</p>
</header>
@@ -15,7 +15,7 @@
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %>
<% end %>
<p class="txt-small">The code sent to <strong><%= email_address_pending_authentication %></strong> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
<p class="txt-small txt-balance">The code sent to <strong><%= email_address_pending_authentication %></strong> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
</div>
<% if Rails.env.development? && flash[:magic_link_code].present? %>
+1 -1
View File
@@ -1,7 +1,7 @@
default_options: &default_options
store_options:
max_age: <%= 60.days.to_i %>
namespace: <%= Rails.env %>
namespace: <%= "#{Rails.env}#{"-#{ENV["CACHE_NAMESPACE"]}" if ENV["CACHE_NAMESPACE"]}" %>
default_connection: &default_connection
database: cache
+19 -16
View File
@@ -16,6 +16,8 @@
# attached to an untracked type (avatar/export) could theoretically be reused in a tracked
# context. This is acceptable - user-accessible blob IDs from untracked contexts are
# basically nonexistent in practice.
#
# Exception: ActionText embeds are allowed to reuse blobs to support copy/paste.
ActiveSupport.on_load(:active_storage_attachment) do
validate :blob_account_matches_record, on: :create
@@ -29,30 +31,31 @@ ActiveSupport.on_load(:active_storage_attachment) do
# bypass tenancy checks via try(:account) returning nil - this is intentional as
# these classes don't participate in storage tracking.
def blob_account_matches_record
return unless record&.try(:account).present?
return if whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
if record&.try(:account).present? && !whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
end
end
end
# Ledger integrity: blob can only have one tracked attachment
def no_tracked_blob_reuse
tracked_record = record&.try(:storage_tracked_record)
return unless tracked_record.present?
return if whitelisted_for_cross_account?
if tracked_record.present? &&
!whitelisted_for_cross_account? &&
!(record_type == "ActionText::RichText" && name == "embeds")
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
end
end
end
@@ -0,0 +1,40 @@
# Fizzy-specific override: ActiveStorage's default purge path uses `delete`,
# which skips attachment callbacks. We need `destroy` so storage ledger detaches
# are recorded and reused blobs (ActionText embeds) aren't purged until the
# last attachment is gone. Keep this local to Fizzy; it's not a Rails default.
module ActiveStorage
module PurgeOnLastAttachment
def purge
@purge_mode = :purge
destroy
purge_blob_if_last(:purge) if destroyed?
ensure
@purge_mode = nil
end
def purge_later
@purge_mode = :purge_later
destroy
purge_blob_if_last(:purge_later) if destroyed?
ensure
@purge_mode = nil
end
private
def purge_dependent_blob_later
if dependent == :purge_later && !@purge_mode
purge_blob_if_last(:purge_later)
end
end
def purge_blob_if_last(mode)
if blob && !blob.attachments.exists?
mode == :purge ? blob.purge : blob.purge_later
end
end
end
end
ActiveSupport.on_load(:active_storage_attachment) do
prepend ActiveStorage::PurgeOnLastAttachment
end
+3
View File
@@ -27,6 +27,9 @@ production: &production
cleanup_exports:
command: "Account::Export.cleanup"
schedule: every hour at minute 20
incineration:
class: "Account::IncinerateDueJob"
schedule: every 8 hours at minute 16
<% if Fizzy.saas? %>
# Metrics
+1
View File
@@ -2,6 +2,7 @@ Rails.application.routes.draw do
root "events#index"
namespace :account do
resource :cancellation, only: [ :create ]
resource :entropy
resource :join_code
resource :settings
@@ -0,0 +1,10 @@
class CreateAccountCancellations < ActiveRecord::Migration[8.2]
def change
create_table :account_cancellations, id: :uuid do |t|
t.uuid :account_id, null: false, index: { unique: true }
t.uuid :initiated_by_id, null: false
t.timestamps
end
end
end
Generated
+9 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
t.index ["user_id"], name: "index_accesses_on_user_id"
end
create_table "account_cancellations", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
t.uuid "initiated_by_id", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true
end
create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "completed_at"
+10 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
t.index ["user_id"], name: "index_accesses_on_user_id"
end
create_table "account_cancellations", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
t.uuid "initiated_by_id", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true
end
create_table "account_exports", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "completed_at"
@@ -469,7 +477,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
t.string "operation", limit: 255, null: false
t.uuid "recordable_id"
t.string "recordable_type", limit: 255
t.string "request_id"
t.string "request_id", limit: 255
t.uuid "user_id"
t.index ["account_id"], name: "index_storage_entries_on_account_id"
t.index ["blob_id"], name: "index_storage_entries_on_blob_id"
+36
View File
@@ -574,6 +574,7 @@ __Response:__
"description_html": "<div class=\"action-text-content\"><p>Hello, World!</p></div>",
"image_url": null,
"tags": ["programming"],
"closed": false,
"golden": false,
"last_active_at": "2025-12-05T19:38:48.553Z",
"created_at": "2025-12-05T19:38:48.540Z",
@@ -594,6 +595,15 @@ __Response:__
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
}
},
"column": {
"id": "03f5v9zkft4hj9qq0lsn9ohcn",
"name": "In Progress",
"color": {
"name": "Lime",
"value": "var(--color-card-4)"
},
"created_at": "2025-12-05T19:36:35.534Z"
},
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
"name": "David Heinemeier Hansson",
@@ -619,6 +629,8 @@ __Response:__
}
```
> **Note:** The `closed` field indicates whether the card is in the "Done" state. The `column` field is only present when the card has been triaged into a column; cards in "Maybe?", "Not Now" or "Done" will not have this field.
### `POST /:account_slug/boards/:board_id/cards`
Creates a new card in a board.
@@ -683,6 +695,14 @@ __Response:__
Returns `204 No Content` on success.
### `DELETE /:account_slug/cards/:card_number/image`
Removes the header image from a card.
__Response:__
Returns `204 No Content` on success.
### `POST /:account_slug/cards/:card_number/closure`
Closes a card.
@@ -767,6 +787,22 @@ __Response:__
Returns `204 No Content` on success.
### `POST /:account_slug/cards/:card_number/goldness`
Marks a card as golden.
__Response:__
Returns `204 No Content` on success.
### `DELETE /:account_slug/cards/:card_number/goldness`
Removes golden status from a card.
__Response:__
Returns `204 No Content` on success.
## Comments
Comments are attached to cards and support rich text.
@@ -1,7 +1,7 @@
class Stripe::WebhooksController < ApplicationController
allow_unauthenticated_access
skip_before_action :require_account
skip_before_action :verify_authenticity_token
skip_forgery_protection
def create
if event = verify_webhook_signature
+4
View File
@@ -4,6 +4,10 @@ module Account::Billing
included do
has_one :subscription, class_name: "Account::Subscription", dependent: :destroy
has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy
set_callback :incinerate, :before, -> { subscription&.cancel }
set_callback :cancel, :after, -> { subscription&.pause }
set_callback :reactivate, :before, -> { subscription&.resume }
end
def plan
+25
View File
@@ -22,4 +22,29 @@ class Account::Subscription < SaasRecord
def next_amount_due
next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price
end
def pause
if stripe_subscription_id.present?
Stripe::Subscription.update(
stripe_subscription_id,
pause_collection: { behavior: "void" }
)
end
end
def resume
if stripe_subscription_id.present?
Stripe::Subscription.update(
stripe_subscription_id,
pause_collection: ""
)
end
end
def cancel
Stripe::Subscription.cancel(stripe_subscription_id) if stripe_subscription_id.present?
rescue Stripe::InvalidRequestError => e
# Subscription already deleted/canceled in Stripe - treat as success
Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}"
end
end
@@ -4,13 +4,13 @@
<% elsif Current.account.exceeding_card_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
<% elsif Current.account.exceeding_storage_limit? %>
Youve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> free storage
Youve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
<% else %>
Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>
<% end %>
</h3>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= format_currency(Plan.paid.price) %>/month</strong> for <strong>unlimited cards</strong>, <strong>unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= format_currency(Plan.paid.price) %>/month</strong> for <strong class="txt-nowrap">unlimited cards</strong>, <strong class="txt-nowrap">unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<%= button_to "Upgrade to #{Plan.paid.name} for #{ format_currency(Plan.paid.price) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
+1
View File
@@ -75,6 +75,7 @@ ssh:
env:
clear:
APP_FQDN: beta<%= @beta_number %>.fizzy-beta.com
CACHE_NAMESPACE: <%= @beta_number %>
RAILS_ENV: beta
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
MYSQL_DATABASE_HOST: fizzy-mysql-primary
+40
View File
@@ -28,4 +28,44 @@ class Account::BillingTest < ActiveSupport::TestCase
account.comp
assert_equal 1, Account::BillingWaiver.where(account: account).count
end
test "cancel callback pauses subscription" do
account = accounts(:"37s")
user = users(:david)
subscription = mock("subscription")
subscription.expects(:pause).once
account.stubs(:subscription).returns(subscription)
account.cancel(initiated_by: user)
end
test "reactivate callback resumes subscription" do
account = accounts(:"37s")
user = users(:david)
# First cancel with a subscription mock
subscription_for_cancel = mock("subscription")
subscription_for_cancel.expects(:pause).once
account.stubs(:subscription).returns(subscription_for_cancel)
account.cancel(initiated_by: user)
# Now stub for reactivation
subscription_for_reactivate = mock("subscription")
subscription_for_reactivate.expects(:resume).once
account.stubs(:subscription).returns(subscription_for_reactivate)
account.reactivate
end
test "incinerate callback cancels subscription before destroying account" do
account = accounts(:"37s")
subscription = mock("subscription")
subscription.expects(:cancel).once
account.stubs(:subscription).returns(subscription)
account.incinerate
end
end
@@ -15,4 +15,106 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid?
assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
end
test "pause pauses Stripe subscription with void behavior" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.expects(:update).with(
"sub_123",
pause_collection: { behavior: "void" }
).returns(true)
subscription.pause
end
test "pause does nothing when no stripe_subscription_id" do
subscription = Account::Subscription.new(stripe_subscription_id: nil)
Stripe::Subscription.expects(:update).never
subscription.pause
end
test "pause raises on Stripe errors" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.stubs(:update).raises(
Stripe::APIConnectionError.new("Network error")
)
assert_raises Stripe::APIConnectionError do
subscription.pause
end
end
test "resume resumes Stripe subscription" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.expects(:update).with(
"sub_123",
pause_collection: ""
).returns(true)
subscription.resume
end
test "resume does nothing when no stripe_subscription_id" do
subscription = Account::Subscription.new(stripe_subscription_id: nil)
Stripe::Subscription.expects(:update).never
subscription.resume
end
test "resume raises on Stripe errors" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.stubs(:update).raises(
Stripe::AuthenticationError.new("Invalid API key")
)
assert_raises Stripe::AuthenticationError do
subscription.resume
end
end
test "cancel cancels Stripe subscription" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.expects(:cancel).with("sub_123").returns(true)
subscription.cancel
end
test "cancel does nothing when no stripe_subscription_id" do
subscription = Account::Subscription.new(stripe_subscription_id: nil)
Stripe::Subscription.expects(:cancel).never
subscription.cancel
end
test "cancel treats 404 as success" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_deleted")
Stripe::Subscription.stubs(:cancel).raises(
Stripe::InvalidRequestError.new("No such subscription", {})
)
assert_nothing_raised do
subscription.cancel
end
end
test "cancel raises on other Stripe errors" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.stubs(:cancel).raises(
Stripe::RateLimitError.new("Rate limit exceeded")
)
assert_raises Stripe::RateLimitError do
subscription.cancel
end
end
end
+4 -2
View File
@@ -10,17 +10,19 @@
#
# Safe to re-run: skips attachments that already have entries (by blob_id + recordable).
#
# IMPORTANT: Before running in production, verify zero historic blob reuse in tracked contexts:
# OPTIONAL: If you want to enforce no-reuse for direct attachments, verify there are
# no existing violations (ActionText embeds may legitimately reuse blobs):
#
# ActiveStorage::Attachment
# .joins(:blob)
# .where(record_type: Storage::TRACKED_RECORD_TYPES)
# .where.not(record_type: "ActionText::RichText")
# .where.not(active_storage_blobs: { account_id: Storage::TEMPLATE_ACCOUNT_ID })
# .select(:blob_id)
# .group(:blob_id)
# .having("COUNT(*) > 1")
# .count
# # Should return empty hash if no reuse exists in tracked contexts
# # Should return empty hash if no direct-attachment reuse exists
#
# If reuse exists (excluding template blobs), fix the data first.
class BackfillStorageLedger
@@ -0,0 +1,46 @@
require "test_helper"
class Account::CancellationsControllerTest < ActionDispatch::IntegrationTest
setup do
@account = accounts(:"37s")
@user = users(:jason)
sign_in_as @user
if @account.respond_to?(:subscription)
Account.any_instance.stubs(:subscription).returns(nil)
end
end
test "an owner can cancel the account" do
assert_difference -> { Account::Cancellation.count }, 1 do
assert_enqueued_emails 1 do
post account_cancellation_url
end
end
assert_redirected_to session_menu_path(script_name: nil)
assert_equal "Account deleted", flash[:notice]
assert @account.reload.cancelled?
assert_equal @user, @account.cancellation.initiated_by
end
test "non-owner cannot cancel the account" do
logout_and_sign_in_as users(:david)
assert_no_difference -> { Account::Cancellation.count } do
post account_cancellation_url
end
assert_response :forbidden
end
test "cancelling an account while in single-tenant mode does nothing" do
with_multi_tenant_mode(false) do
assert_no_difference -> { Account::Cancellation.count } do
post account_cancellation_url
end
assert_not @account.reload.cancelled?
end
end
end
@@ -78,4 +78,20 @@ class My::MenusControllerTest < ActionDispatch::IntegrationTest
get my_menu_path, headers: { "If-None-Match" => etag }
assert_response :not_modified
end
test "show excludes cancelled accounts" do
# Create another account for the same identity
another_account = Account.create!(external_account_id: 9999996, name: "Cancelled Account")
another_user = @user.identity.users.create!(account: another_account, name: "Kevin", role: "owner")
# Cancel the other account
another_account.cancel(initiated_by: another_user)
get my_menu_path
assert_response :success
# The response should include active account but not cancelled one
assert_select "a[href*='#{@account.slug}']"
assert_select "a[href*='#{another_account.slug}']", count: 0
end
end
+1 -1
View File
@@ -39,7 +39,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
# Searching with non-existent card id
get search_path(q: "999999", script_name: "/#{@account.external_account_id}")
assert_select "form[data-controller='auto-submit']", count: 0
assert_select ".search__empty", text: "No matches"
assert_select ".search__blank-slate", text: "No matches"
end
test "search highlights matched terms with proper HTML marks" do
@@ -47,4 +47,24 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "show excludes cancelled accounts" do
sign_in_as @identity
@identity.users.delete_all
account1 = Account.create!(external_account_id: 9999994, name: "Active Account")
account2 = Account.create!(external_account_id: 9999995, name: "Cancelled Account")
user1 = @identity.users.create!(account: account1, name: "Kevin", role: "owner")
user2 = @identity.users.create!(account: account2, name: "Kevin", role: "owner")
# Cancel one account
account2.cancel(initiated_by: user2)
untenanted do
get session_menu_url
end
# Should redirect to the only active account
assert_response :redirect
assert_redirected_to root_url(script_name: account1.slug)
end
end
+6
View File
@@ -9,3 +9,9 @@ initech:
name: Initech LLC
external_account_id: <%= ActiveRecord::FixtureSet.identify("initech") %>
cards_count: 0
acme:
id: <%= ActiveRecord::FixtureSet.identify("acme", :uuid) %>
name: ACME
external_account_id: <%= ActiveRecord::FixtureSet.identify("acme") %>
cards_count: 0
@@ -0,0 +1,67 @@
require "test_helper"
class Account::IncinerateDueJobTest < ActiveJob::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
# Stub Stripe methods only in SaaS mode
if defined?(Stripe::Subscription)
Stripe::Subscription.stubs(:update).returns(true)
Stripe::Subscription.stubs(:cancel).returns(true)
end
end
test "finds accounts up for incineration" do
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
Account.any_instance.expects(:incinerate).once
Account::IncinerateDueJob.perform_now
end
test "incinerates each old cancelled account" do
# Cancel the test account
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
# Just verify it gets incinerated
assert_difference -> { Account.count }, -1 do
Account::IncinerateDueJob.perform_now
end
end
test "skips recent cancellations" do
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 29.days.ago)
assert_no_difference -> { Account.count } do
Account::IncinerateDueJob.perform_now
end
end
test "handles cursor pagination correctly" do
# Cancel and age the account
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
# Verify it processes accounts in the scope
assert_difference -> { Account.count }, -1 do
Account::IncinerateDueJob.perform_now
end
end
test "only incinerates accounts past grace period" do
# Account at 29 days (within grace period - should not be incinerated)
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 29.days.ago)
assert_no_difference -> { Account.count } do
Account::IncinerateDueJob.perform_now
end
# The account should still exist
assert Account.exists?(@account.id)
end
end
+57
View File
@@ -0,0 +1,57 @@
require "test_helper"
class AccountMailerTest < ActionMailer::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
@account.cancel(initiated_by: @user)
@cancellation = @account.cancellation
end
test "cancellation sends to initiating user" do
email = AccountMailer.cancellation(@cancellation)
assert_emails 1 do
email.deliver_now
end
assert_equal [ @user.identity.email_address ], email.to
end
test "cancellation includes account name" do
email = AccountMailer.cancellation(@cancellation)
assert_match @account.name, email.body.encoded
end
test "cancellation includes support email" do
email = AccountMailer.cancellation(@cancellation)
assert_match "support@fizzy.do", email.body.encoded
end
test "cancellation has correct subject" do
email = AccountMailer.cancellation(@cancellation)
assert_equal "Your Fizzy account was cancelled", email.subject
end
test "cancellation has both HTML and text parts" do
email = AccountMailer.cancellation(@cancellation)
assert email.html_part.present?, "Email should have HTML part"
assert email.text_part.present?, "Email should have text part"
end
test "cancellation mentions account access is removed" do
email = AccountMailer.cancellation(@cancellation)
assert_match /no one can access/i, email.body.encoded
end
test "cancellation mentions data will be deleted" do
email = AccountMailer.cancellation(@cancellation)
assert_match /deleted/i, email.body.encoded
end
end
+23
View File
@@ -80,4 +80,27 @@ class AccessTest < ActiveSupport::TestCase
assert_not card.watched_by?(kevin)
end
test "pins are destroyed when access is lost" do
kevin = users(:kevin)
board = boards(:writebook)
card = cards(:logo) # Kevin has pinned this card
other_board = boards(:miltons_wish_list)
other_card = cards(:radio)
other_board.accesses.grant_to(kevin)
other_card.pin_by(kevin)
assert card.pinned_by?(kevin)
assert other_card.pinned_by?(kevin)
kevin_access = accesses(:writebook_kevin)
perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do
kevin_access.destroy
end
assert_not card.pinned_by?(kevin)
assert other_card.pinned_by?(kevin), "Pin on other board should not be destroyed"
end
end
+79
View File
@@ -0,0 +1,79 @@
require "test_helper"
class Account::CancellableTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
end
test "cancel" do
assert_difference -> { Account::Cancellation.count }, 1 do
assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do
@account.cancel(initiated_by: @user)
end
end
assert @account.cancelled?
assert_equal @user, @account.cancellation.initiated_by
end
test "cancel does nothing if already cancelled" do
@account.cancel(initiated_by: @user)
assert_no_changes -> { @account.cancellation.reload.created_at } do
@account.cancel(initiated_by: @user)
end
end
test "cancel does nothing when in single-tenant mode" do
Account.stubs(:accepting_signups?).returns(false)
assert_no_difference -> { Account::Cancellation.count } do
@account.cancel(initiated_by: @user)
end
assert_not @account.cancelled?
end
test "cancelled? returns true when cancellation exists" do
assert_not @account.cancelled?
@account.cancel(initiated_by: @user)
assert @account.cancelled?
end
test "reactivate" do
@account.cancel(initiated_by: @user)
assert @account.cancelled?
@account.reactivate
@account.reload
assert_not @account.cancelled?
assert_nil @account.cancellation
end
test "reactivate does nothing if not cancelled" do
assert_not @account.cancelled?
assert_nothing_raised do
@account.reactivate
end
assert_not @account.cancelled?
end
test "active scope excludes cancelled accounts" do
account2 = accounts(:initech)
initial_active_count = Account.active.count
@account.cancel(initiated_by: @user)
assert_equal initial_active_count - 1, Account.active.count
assert_not_includes Account.active, @account
assert_includes Account.active, account2
end
end
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class Account::CancellationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+26
View File
@@ -0,0 +1,26 @@
require "test_helper"
class Account::IncineratableTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
end
test "incinerate destroys account" do
assert_difference -> { Account.count }, -1 do
@account.incinerate
end
assert_not Account.exists?(@account.id)
end
test "due_for_incineration finds old cancellations" do
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
assert_equal [ @account ], Account.due_for_incineration
@account.cancellation.update!(created_at: 29.days.ago)
assert Account.due_for_incineration.empty?
end
end
+17
View File
@@ -1,6 +1,8 @@
require "test_helper"
class Notification::BundleTest < ActiveSupport::TestCase
include ActionMailer::TestHelper
setup do
@user = users(:david)
@user.settings.bundle_email_every_few_hours!
@@ -160,4 +162,19 @@ class Notification::BundleTest < ActiveSupport::TestCase
assert_includes @user.notification_bundles.last.notifications, first_notification
assert_includes @user.notification_bundles.last.notifications, second_notification
end
test "deliver does not send email for cancelled accounts" do
@user.notifications.create!(source: events(:logo_published), creator: @user)
bundle = @user.notification_bundles.pending.last
@user.account.cancel(initiated_by: @user)
assert_no_emails do
deliver_enqueued_emails do
bundle.deliver
end
end
assert bundle.delivered?, "Bundle should be marked as delivered even if not sent"
end
end
+32
View File
@@ -0,0 +1,32 @@
require "test_helper"
class NotificationPusherTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@notification = @user.notifications.create!(
source: events(:logo_published),
creator: users(:jason)
)
@pusher = NotificationPusher.new(@notification)
@user.push_subscriptions.create!(
endpoint: "https://fcm.googleapis.com/fcm/send/test123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
end
test "push does not send notifications for cancelled accounts" do
@user.account.cancel(initiated_by: @user)
result = @pusher.push
assert_nil result, "Should not push notifications for cancelled accounts"
end
test "push sends notifications for active accounts with subscriptions" do
result = @pusher.push
assert_not_nil result, "Should push notifications for active accounts with subscriptions"
end
end
+77
View File
@@ -33,6 +33,83 @@ class Storage::NoReuseTest < ActiveSupport::TestCase
assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count
end
test "allows reuse for ActionText embeds" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "embed.jpg",
content_type: "image/jpeg"
embed_html = ActionText::Attachment.from_attachable(blob).to_html
card1 = @board.cards.create!(title: "Card 1", creator: users(:david))
card1.update!(description: "<p>#{embed_html}</p>")
card1.reload
card2 = @board.cards.create!(title: "Card 2", creator: users(:david))
card2.update!(description: "<p>#{embed_html}</p>")
card2.reload
assert_equal 2, ActiveStorage::Attachment.where(
record_type: "ActionText::RichText",
name: "embeds",
blob_id: blob.id
).count
end
test "purge_later does not purge blob when still attached elsewhere" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "embed.jpg",
content_type: "image/jpeg"
embed_html = ActionText::Attachment.from_attachable(blob).to_html
card1 = @board.cards.create!(title: "Card 1", creator: users(:david))
card1.update!(description: "<p>#{embed_html}</p>")
card2 = @board.cards.create!(title: "Card 2", creator: users(:david))
card2.update!(description: "<p>#{embed_html}</p>")
attachment = ActiveStorage::Attachment.find_by(
record: card1.rich_text_description,
name: "embeds",
blob_id: blob.id
)
assert_no_enqueued_jobs only: ActiveStorage::PurgeJob do
attachment.purge_later
end
assert ActiveStorage::Blob.exists?(blob.id)
assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count
end
test "purge_later enqueues purge when last attachment is removed" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "embed.jpg",
content_type: "image/jpeg"
embed_html = ActionText::Attachment.from_attachable(blob).to_html
card = @board.cards.create!(title: "Card", creator: users(:david))
card.update!(description: "<p>#{embed_html}</p>")
card.reload
attachment = ActiveStorage::Attachment.find_by(
record: card.rich_text_description,
name: "embeds",
blob_id: blob.id
)
assert_enqueued_with job: ActiveStorage::PurgeJob, args: [ blob ] do
attachment.purge_later
end
end
test "rejects cross-account blob attachment" do
other_account = Account.create!(name: "Other")
other_board = other_account.boards.create!(name: "Other Board", creator: users(:david))
+58
View File
@@ -0,0 +1,58 @@
require "test_helper"
class Webhook::TriggerableTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@board = boards(:writebook)
@card = @board.cards.first
@webhook = @board.webhooks.create!(
name: "Test Webhook",
url: "https://example.com/webhook",
subscribed_actions: [ "card_published" ]
)
# Create a test event
@event = @board.events.create!(
creator: users(:david),
eventable: @card,
action: "card_published"
)
@user = users(:david)
end
test "trigger creates delivery for active accounts" do
assert_difference -> { Webhook::Delivery.count }, 1 do
@webhook.trigger(@event)
end
delivery = Webhook::Delivery.last
assert_equal @event, delivery.event
assert_equal @webhook, delivery.webhook
end
test "trigger skips cancelled accounts" do
@account.cancel(initiated_by: @user)
assert_no_difference -> { Webhook::Delivery.count } do
@webhook.trigger(@event)
end
end
test "triggered_by scope finds webhooks for event" do
other_webhook = @board.webhooks.create!(
name: "Other Webhook",
url: "https://example.com/other",
subscribed_actions: [ "card_closed" ]
)
matching_webhooks = Webhook.triggered_by(@event)
assert_includes matching_webhooks, @webhook
assert_not_includes matching_webhooks, other_webhook
end
test "active scope only returns active webhooks" do
@webhook.update!(active: false)
assert_not_includes Webhook.active, @webhook
end
end