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:
+31
-32
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -343,5 +343,9 @@
|
||||
grid-area: closure-message;
|
||||
margin-block-start: 0.5ch;
|
||||
padding-inline: 1ch;
|
||||
|
||||
form {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
&:has(.card--notification) {
|
||||
.notifications-list__empty-message {
|
||||
.notifications-list__blank-slate {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ summary {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.search__empty {
|
||||
.search__blank-slate {
|
||||
margin-block: 3em;
|
||||
margin-inline: auto;
|
||||
inline-size: fit-content;
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class ExportAccountDataJob < ApplicationJob
|
||||
queue_as :backend
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(export)
|
||||
export.build
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class NotifyRecipientsJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(notifiable)
|
||||
notifiable.notify_recipients
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class PushNotificationJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(notification)
|
||||
NotificationPusher.new(notification).push
|
||||
end
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Webhook::DeliveryJob < ApplicationJob
|
||||
queue_as :webhooks
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(delivery)
|
||||
delivery.deliver
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
class Account::Cancellation < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :initiated_by, class_name: "User"
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 doesn’t 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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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? %>
|
||||
You’ve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
|
||||
<% elsif Current.account.exceeding_storage_limit? %>
|
||||
You’ve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> free storage
|
||||
You’ve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
|
||||
<% else %>
|
||||
You’ve 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? %> it’s 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? %> it’s 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 } } %>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+6
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::CancellationTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user