Hello, World!
diff --git a/Gemfile.lock b/Gemfile.lock
index 4b3ce6e44..0e48985b0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 98cf833bc..f340b4ae4 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -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)
diff --git a/app/assets/stylesheets/blank-slates.css b/app/assets/stylesheets/blank-slates.css
index aa221c452..5db13ef7f 100644
--- a/app/assets/stylesheets/blank-slates.css
+++ b/app/assets/stylesheets/blank-slates.css
@@ -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;
}
}
diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css
index 07653e7fa..89638fc12 100644
--- a/app/assets/stylesheets/buttons.css
+++ b/app/assets/stylesheets/buttons.css
@@ -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);
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index 6aaacaf8b..2c0c67ef9 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -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 {
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index 240dadb4b..8d1340c23 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -343,5 +343,9 @@
grid-area: closure-message;
margin-block-start: 0.5ch;
padding-inline: 1ch;
+
+ form {
+ display: inline;
+ }
}
}
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index 2ae664dcd..006cbb985 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -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;
}
}
diff --git a/app/assets/stylesheets/golden-effect.css b/app/assets/stylesheets/golden-effect.css
index 423286779..c12af8575 100644
--- a/app/assets/stylesheets/golden-effect.css
+++ b/app/assets/stylesheets/golden-effect.css
@@ -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;
+ }
}
}
diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css
index ada7324d2..91ff5750d 100644
--- a/app/assets/stylesheets/notifications.css
+++ b/app/assets/stylesheets/notifications.css
@@ -30,7 +30,7 @@
}
&:has(.card--notification) {
- .notifications-list__empty-message {
+ .notifications-list__blank-slate {
display: none;
}
}
diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css
index fcd4563ca..5691ac5e5 100644
--- a/app/assets/stylesheets/search.css
+++ b/app/assets/stylesheets/search.css
@@ -71,7 +71,7 @@ summary {
text-align: start;
}
- .search__empty {
+ .search__blank-slate {
margin-block: 3em;
margin-inline: auto;
inline-size: fit-content;
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index 62304d020..76dedfe12 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -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; }
diff --git a/app/controllers/account/cancellations_controller.rb b/app/controllers/account/cancellations_controller.rb
new file mode 100644
index 000000000..b6ac82ab4
--- /dev/null
+++ b/app/controllers/account/cancellations_controller.rb
@@ -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
diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb
index f30d518cc..a6456a050 100644
--- a/app/controllers/concerns/authorization.rb
+++ b/app/controllers/concerns/authorization.rb
@@ -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 }
diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb
index 32b3da004..24fa2b085 100644
--- a/app/controllers/my/menus_controller.rb
+++ b/app/controllers/my/menus_controller.rb
@@ -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
diff --git a/app/controllers/notifications/unsubscribes_controller.rb b/app/controllers/notifications/unsubscribes_controller.rb
index f7486d91e..d1842b169 100644
--- a/app/controllers/notifications/unsubscribes_controller.rb
+++ b/app/controllers/notifications/unsubscribes_controller.rb
@@ -1,6 +1,6 @@
class Notifications::UnsubscribesController < ApplicationController
allow_unauthenticated_access
- skip_before_action :verify_authenticity_token
+ skip_forgery_protection
before_action :set_user
diff --git a/app/controllers/public/base_controller.rb b/app/controllers/public/base_controller.rb
index 018b1ab36..503c914e4 100644
--- a/app/controllers/public/base_controller.rb
+++ b/app/controllers/public/base_controller.rb
@@ -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
diff --git a/app/controllers/sessions/menus_controller.rb b/app/controllers/sessions/menus_controller.rb
index 984e83a43..5fd885c45 100644
--- a/app/controllers/sessions/menus_controller.rb
+++ b/app/controllers/sessions/menus_controller.rb
@@ -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)
diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb
index a92fb2824..b3e98b30e 100644
--- a/app/helpers/columns_helper.rb
+++ b/app/helpers/columns_helper.rb
@@ -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 }
diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js
index 1a9ba4174..e432a054f 100644
--- a/app/javascript/controllers/collapsible_columns_controller.js
+++ b/app/javascript/controllers/collapsible_columns_controller.js
@@ -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()
diff --git a/app/jobs/account/incinerate_due_job.rb b/app/jobs/account/incinerate_due_job.rb
new file mode 100644
index 000000000..5bb7b35eb
--- /dev/null
+++ b/app/jobs/account/incinerate_due_job.rb
@@ -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
diff --git a/app/jobs/board/clean_inaccessible_data_job.rb b/app/jobs/board/clean_inaccessible_data_job.rb
index b2cdff085..5aa1eb0eb 100644
--- a/app/jobs/board/clean_inaccessible_data_job.rb
+++ b/app/jobs/board/clean_inaccessible_data_job.rb
@@ -1,4 +1,6 @@
class Board::CleanInaccessibleDataJob < ApplicationJob
+ discard_on ActiveJob::DeserializationError
+
def perform(user, board)
board.clean_inaccessible_data_for(user)
end
diff --git a/app/jobs/card/activity_spike/detection_job.rb b/app/jobs/card/activity_spike/detection_job.rb
index 2f5142d0d..82278e5d7 100644
--- a/app/jobs/card/activity_spike/detection_job.rb
+++ b/app/jobs/card/activity_spike/detection_job.rb
@@ -1,4 +1,6 @@
class Card::ActivitySpike::DetectionJob < ApplicationJob
+ discard_on ActiveJob::DeserializationError
+
def perform(card)
card.detect_activity_spikes
end
diff --git a/app/jobs/card/remove_inaccessible_notifications_job.rb b/app/jobs/card/remove_inaccessible_notifications_job.rb
index db7dec4e3..018c6cf8d 100644
--- a/app/jobs/card/remove_inaccessible_notifications_job.rb
+++ b/app/jobs/card/remove_inaccessible_notifications_job.rb
@@ -1,4 +1,6 @@
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
+ discard_on ActiveJob::DeserializationError
+
def perform(card)
card.remove_inaccessible_notifications
end
diff --git a/app/jobs/event/webhook_dispatch_job.rb b/app/jobs/event/webhook_dispatch_job.rb
index 1df418cc8..96f01ba50 100644
--- a/app/jobs/event/webhook_dispatch_job.rb
+++ b/app/jobs/event/webhook_dispatch_job.rb
@@ -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|
diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_account_data_job.rb
index 82b5071cb..c0286c736 100644
--- a/app/jobs/export_account_data_job.rb
+++ b/app/jobs/export_account_data_job.rb
@@ -1,6 +1,8 @@
class ExportAccountDataJob < ApplicationJob
queue_as :backend
+ discard_on ActiveJob::DeserializationError
+
def perform(export)
export.build
end
diff --git a/app/jobs/mention/create_job.rb b/app/jobs/mention/create_job.rb
index 816782754..4376ac493 100644
--- a/app/jobs/mention/create_job.rb
+++ b/app/jobs/mention/create_job.rb
@@ -1,4 +1,6 @@
class Mention::CreateJob < ApplicationJob
+ discard_on ActiveJob::DeserializationError
+
def perform(record, mentioner:)
record.create_mentions(mentioner:)
end
diff --git a/app/jobs/notification/bundle/deliver_all_job.rb b/app/jobs/notification/bundle/deliver_all_job.rb
index 79aeaed16..d3e2cb738 100644
--- a/app/jobs/notification/bundle/deliver_all_job.rb
+++ b/app/jobs/notification/bundle/deliver_all_job.rb
@@ -1,6 +1,8 @@
class Notification::Bundle::DeliverAllJob < ApplicationJob
queue_as :backend
+ discard_on ActiveJob::DeserializationError
+
def perform
Notification::Bundle.deliver_all
end
diff --git a/app/jobs/notification/bundle/deliver_job.rb b/app/jobs/notification/bundle/deliver_job.rb
index 11f6bcda5..b4059a779 100644
--- a/app/jobs/notification/bundle/deliver_job.rb
+++ b/app/jobs/notification/bundle/deliver_job.rb
@@ -3,6 +3,8 @@ class Notification::Bundle::DeliverJob < ApplicationJob
queue_as :backend
+ discard_on ActiveJob::DeserializationError
+
def perform(bundle)
bundle.deliver
end
diff --git a/app/jobs/notify_recipients_job.rb b/app/jobs/notify_recipients_job.rb
index 6083622fd..bba5898da 100644
--- a/app/jobs/notify_recipients_job.rb
+++ b/app/jobs/notify_recipients_job.rb
@@ -1,4 +1,6 @@
class NotifyRecipientsJob < ApplicationJob
+ discard_on ActiveJob::DeserializationError
+
def perform(notifiable)
notifiable.notify_recipients
end
diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb
index 124366967..c912e141d 100644
--- a/app/jobs/push_notification_job.rb
+++ b/app/jobs/push_notification_job.rb
@@ -1,4 +1,6 @@
class PushNotificationJob < ApplicationJob
+ discard_on ActiveJob::DeserializationError
+
def perform(notification)
NotificationPusher.new(notification).push
end
diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb
index cd3bf3803..43574f428 100644
--- a/app/jobs/storage/reconcile_job.rb
+++ b/app/jobs/storage/reconcile_job.rb
@@ -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)
diff --git a/app/jobs/webhook/delivery_job.rb b/app/jobs/webhook/delivery_job.rb
index 95102b9d6..a6e324985 100644
--- a/app/jobs/webhook/delivery_job.rb
+++ b/app/jobs/webhook/delivery_job.rb
@@ -1,6 +1,8 @@
class Webhook::DeliveryJob < ApplicationJob
queue_as :webhooks
+ discard_on ActiveJob::DeserializationError
+
def perform(delivery)
delivery.deliver
end
diff --git a/app/mailers/account_mailer.rb b/app/mailers/account_mailer.rb
new file mode 100644
index 000000000..5cb6616f8
--- /dev/null
+++ b/app/mailers/account_mailer.rb
@@ -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
diff --git a/app/models/account.rb b/app/models/account.rb
index f9b2589c0..34b63b688 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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
diff --git a/app/models/account/cancellable.rb b/app/models/account/cancellable.rb
new file mode 100644
index 000000000..f10624245
--- /dev/null
+++ b/app/models/account/cancellable.rb
@@ -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
diff --git a/app/models/account/cancellation.rb b/app/models/account/cancellation.rb
new file mode 100644
index 000000000..ba8e73bed
--- /dev/null
+++ b/app/models/account/cancellation.rb
@@ -0,0 +1,4 @@
+class Account::Cancellation < ApplicationRecord
+ belongs_to :account
+ belongs_to :initiated_by, class_name: "User"
+end
diff --git a/app/models/account/incineratable.rb b/app/models/account/incineratable.rb
new file mode 100644
index 000000000..c734c0768
--- /dev/null
+++ b/app/models/account/incineratable.rb
@@ -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
diff --git a/app/models/board/accessible.rb b/app/models/board/accessible.rb
index 2f668ae4f..e5b86dbe1 100644
--- a/app/models/board/accessible.rb
+++ b/app/models/board/accessible.rb
@@ -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
diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb
index 8617a0f99..fb57a4fd0 100644
--- a/app/models/notification/bundle.rb
+++ b/app/models/notification/bundle.rb
@@ -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
diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb
index acb5603a1..d6425e561 100644
--- a/app/models/notification_pusher.rb
+++ b/app/models/notification_pusher.rb
@@ -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
diff --git a/app/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb
index 443c801fd..7f7ab5771 100644
--- a/app/models/webhook/triggerable.rb
+++ b/app/models/webhook/triggerable.rb
@@ -7,6 +7,6 @@ module Webhook::Triggerable
end
def trigger(event)
- deliveries.create!(event: event)
+ deliveries.create!(event: event) unless account.cancelled?
end
end
diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb
new file mode 100644
index 000000000..5c493edd5
--- /dev/null
+++ b/app/views/account/settings/_cancellation.html.erb
@@ -0,0 +1,26 @@
+<% if Current.account.cancellable? && Current.user.owner? %>
+ Delete your Fizzy accountCancel account
+
Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…
- <%= 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) %>Your Fizzy account <%= @account.name %> was cancelled.
+ +If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible.
diff --git a/app/views/mailers/account_mailer/cancellation.text.erb b/app/views/mailers/account_mailer/cancellation.text.erb new file mode 100644 index 000000000..3cfaf2689 --- /dev/null +++ b/app/views/mailers/account_mailer/cancellation.text.erb @@ -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. diff --git a/app/views/my/menus/_jump.html.erb b/app/views/my/menus/_jump.html.erb index 1f1ea70c9..f22bedf23 100644 --- a/app/views/my/menus/_jump.html.erb +++ b/app/views/my/menus/_jump.html.erb @@ -26,7 +26,7 @@ <%= yield %> - diff --git a/app/views/my/menus/show.html.erb b/app/views/my/menus/show.html.erb index cb3e6310e..a942c2af7 100644 --- a/app/views/my/menus/show.html.erb +++ b/app/views/my/menus/show.html.erb @@ -9,6 +9,6 @@ <% end %> <% end %> diff --git a/app/views/notifications/index/_unread_notifications.html.erb b/app/views/notifications/index/_unread_notifications.html.erb index b8b9618d1..4890a4822 100644 --- a/app/views/notifications/index/_unread_notifications.html.erb +++ b/app/views/notifications/index/_unread_notifications.html.erb @@ -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" } %> <% else %> - <% end %> diff --git a/app/views/public/boards/columns/not_nows/show.html.erb b/app/views/public/boards/columns/not_nows/show.html.erb index 786cb7afb..3dad8bcfe 100644 --- a/app/views/public/boards/columns/not_nows/show.html.erb +++ b/app/views/public/boards/columns/not_nows/show.html.erb @@ -18,7 +18,7 @@ <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %> -+
Then enter the verification code included in the email below:
The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
+The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
Hello, World!
To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= format_currency(Plan.paid.price) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.
+To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= format_currency(Plan.paid.price) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.
<%= 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 } } %> diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 14bf89698..d5fce9d32 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -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 diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 7283f47da..2fe923c1a 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -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 diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb index 99612ecea..ff5b0837a 100644 --- a/saas/test/models/account/subscription_test.rb +++ b/saas/test/models/account/subscription_test.rb @@ -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 diff --git a/script/migrations/backfill-storage-ledger.rb b/script/migrations/backfill-storage-ledger.rb index 5d89342fc..aa3ec793f 100644 --- a/script/migrations/backfill-storage-ledger.rb +++ b/script/migrations/backfill-storage-ledger.rb @@ -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 diff --git a/test/controllers/account/cancellations_controller_test.rb b/test/controllers/account/cancellations_controller_test.rb new file mode 100644 index 000000000..2e35534a6 --- /dev/null +++ b/test/controllers/account/cancellations_controller_test.rb @@ -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 diff --git a/test/controllers/my/menus_controller_test.rb b/test/controllers/my/menus_controller_test.rb index bbd560764..189752fe4 100644 --- a/test/controllers/my/menus_controller_test.rb +++ b/test/controllers/my/menus_controller_test.rb @@ -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 diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 14bd91e64..70ec15da9 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -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 diff --git a/test/controllers/sessions/menus_controller_test.rb b/test/controllers/sessions/menus_controller_test.rb index 4be2dd415..701de848d 100644 --- a/test/controllers/sessions/menus_controller_test.rb +++ b/test/controllers/sessions/menus_controller_test.rb @@ -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 diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index c1c83ee51..5ce9567d4 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -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 diff --git a/test/jobs/account/incinerate_due_job_test.rb b/test/jobs/account/incinerate_due_job_test.rb new file mode 100644 index 000000000..6c53857a7 --- /dev/null +++ b/test/jobs/account/incinerate_due_job_test.rb @@ -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 diff --git a/test/mailers/account_mailer_test.rb b/test/mailers/account_mailer_test.rb new file mode 100644 index 000000000..8d41ddf95 --- /dev/null +++ b/test/mailers/account_mailer_test.rb @@ -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 diff --git a/test/models/access_test.rb b/test/models/access_test.rb index 10787e5d3..b4580420d 100644 --- a/test/models/access_test.rb +++ b/test/models/access_test.rb @@ -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 diff --git a/test/models/account/cancellable_test.rb b/test/models/account/cancellable_test.rb new file mode 100644 index 000000000..beb63a057 --- /dev/null +++ b/test/models/account/cancellable_test.rb @@ -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 diff --git a/test/models/account/cancellation_test.rb b/test/models/account/cancellation_test.rb new file mode 100644 index 000000000..ac88d46df --- /dev/null +++ b/test/models/account/cancellation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::CancellationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/account/incineratable_test.rb b/test/models/account/incineratable_test.rb new file mode 100644 index 000000000..021f751ba --- /dev/null +++ b/test/models/account/incineratable_test.rb @@ -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 diff --git a/test/models/notification/bundle_test.rb b/test/models/notification/bundle_test.rb index 612087682..2a8b242af 100644 --- a/test/models/notification/bundle_test.rb +++ b/test/models/notification/bundle_test.rb @@ -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 diff --git a/test/models/notification_pusher_test.rb b/test/models/notification_pusher_test.rb new file mode 100644 index 000000000..21b9e202b --- /dev/null +++ b/test/models/notification_pusher_test.rb @@ -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 diff --git a/test/models/storage/no_reuse_test.rb b/test/models/storage/no_reuse_test.rb index 5af07511f..e876fe50c 100644 --- a/test/models/storage/no_reuse_test.rb +++ b/test/models/storage/no_reuse_test.rb @@ -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: "#{embed_html}
") + card1.reload + + card2 = @board.cards.create!(title: "Card 2", creator: users(:david)) + card2.update!(description: "#{embed_html}
") + 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: "#{embed_html}
") + + card2 = @board.cards.create!(title: "Card 2", creator: users(:david)) + card2.update!(description: "#{embed_html}
") + + 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: "#{embed_html}
") + 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)) diff --git a/test/models/webhook/triggerable_test.rb b/test/models/webhook/triggerable_test.rb new file mode 100644 index 000000000..bd6d4b0a8 --- /dev/null +++ b/test/models/webhook/triggerable_test.rb @@ -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