Merge branch 'main' into smooth-theme-change

This commit is contained in:
Jason Zimdars
2025-12-22 11:34:48 -06:00
290 changed files with 5808 additions and 865 deletions
+8
View File
@@ -9,3 +9,11 @@ paths = [
'''docs/''',
'''test/''',
]
[[rules]]
id = "basecamp-integration-url"
description = "Basecamp Integration URL"
regex = '''https://[^\s]*?([0-9a-fA-F]{16,})'''
[rules.allowlist]
regexTarget = "match"
regexes = ['''github\.com''']
+2
View File
@@ -11,3 +11,5 @@ AllCops:
Exclude:
- 'db/migrate/**/*'
- 'db/schema*.rb'
- 'saas/db/migrate/**/*'
- 'saas/db/saas_schema.rb'
+15 -9
View File
@@ -122,8 +122,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1187.0)
aws-sdk-core (3.239.1)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -134,7 +134,7 @@ GEM
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.205.0)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -144,7 +144,7 @@ GEM
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.5.0)
bigdecimal (3.3.1)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
msgpack (~> 1.2)
@@ -190,6 +190,7 @@ GEM
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.12.1)
@@ -267,7 +268,7 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
mittens (0.3.0)
mittens (0.3.1)
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
@@ -298,6 +299,8 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
@@ -422,6 +425,7 @@ GEM
sqlite3 (2.8.0-arm-linux-gnu)
sqlite3 (2.8.0-arm-linux-musl)
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
@@ -435,10 +439,11 @@ GEM
railties (>= 6.0.0)
stringio (3.1.9)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.4.4)
trilogy (2.9.0)
tsort (0.2.0)
@@ -481,6 +486,7 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin-25
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
+2 -2
View File
@@ -3,10 +3,10 @@ eval_gemfile "Gemfile"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
# SaaS-only functionality
gem "activeresource", require: "active_resource"
gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", bc: "fizzy-saas"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984"
+32 -31
View File
@@ -19,28 +19,6 @@ GIT
rails (>= 7.0)
rainbow
GIT
remote: https://github.com/basecamp/fizzy-saas
revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143
specs:
fizzy-saas (0.1.0)
audits1984
console1984
prometheus-client-mmap (~> 1.4.0)
queenbee
rails (>= 8.1.0.beta1)
rails_structured_logging
sentry-rails
sentry-ruby
yabeda
yabeda-actioncable
yabeda-activejob
yabeda-gc
yabeda-http_requests
yabeda-prometheus-mmap
yabeda-puma-plugin
yabeda-rails (>= 0.10)
GIT
remote: https://github.com/basecamp/lexxy
revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8
@@ -181,6 +159,27 @@ GIT
tsort (>= 0.2)
zeitwerk (~> 2.6)
PATH
remote: saas
specs:
fizzy-saas (0.1.0)
audits1984
console1984
prometheus-client-mmap (~> 1.4.0)
queenbee
rails (>= 8.1.0.beta1)
rails_structured_logging
sentry-rails
sentry-ruby
yabeda
yabeda-actioncable
yabeda-activejob
yabeda-gc
yabeda-http_requests
yabeda-prometheus-mmap
yabeda-puma-plugin
yabeda-rails (>= 0.10)
GEM
remote: https://rubygems.org/
specs:
@@ -201,8 +200,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1187.0)
aws-sdk-core (3.239.1)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -213,7 +212,7 @@ GEM
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.205.0)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -223,7 +222,7 @@ GEM
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.5.0)
bigdecimal (3.3.1)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
msgpack (~> 1.2)
@@ -347,7 +346,7 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
mittens (0.3.0)
mittens (0.3.1)
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
@@ -558,11 +557,12 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.9)
stripe (18.0.1)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.4.4)
trilogy (2.9.0)
tsort (0.2.0)
@@ -684,6 +684,7 @@ DEPENDENCIES
solid_queue (~> 1.2)
sqlite3 (>= 2.0)
stimulus-rails
stripe (~> 18.0)
thruster
trilogy (~> 2.9)
turbo-rails
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3" /></svg>

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -1,4 +1,4 @@
@layer reset, base, components, modules, utilities, mobile_app, android, ios;
@layer reset, base, components, modules, utilities, native, platform;
:root {
/* Spacing */
@@ -79,6 +79,7 @@
--z-tooltip: 50;
--z-bar: 60;
--z-tray: 61;
--z-welcome: 62;
/* OKLCH colors: Fixed */
--lch-black: 0% 0 0;
+7
View File
@@ -0,0 +1,7 @@
@layer platform {
[data-platform~=android] {
.hide-on-android {
display: none;
}
}
}
@@ -1,5 +0,0 @@
@layer android {
.hide-in-android-app {
display: none;
}
}
@@ -71,6 +71,15 @@
33% { background-color: var(--color-border-darker); scale: 1; }
}
@keyframes wiggle {
0% { transform: rotate(0deg); }
20% { transform: rotate(3deg); }
40% { transform: rotate(-3deg); }
60% { transform: rotate(3deg); }
80% { transform: rotate(-3deg); }
100% { transform: rotate(0deg); }
}
@keyframes wobble {
0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); }
15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; }
@@ -461,7 +461,23 @@
font-size: clamp(0.6rem, 0.85cqi, 100px);
}
.card__comments {
--column-gap: 0.8ch;
display: flex;
margin-block-end: calc(var(--block-space) * -1.7);
margin-inline-end: calc(var(--card-padding-inline) * -0.5);
.icon {
--icon-size: 1.6em;
color: var(--card-color);
}
}
.card__steps {
--column-gap: 0.8ch;
display: flex;
}
@@ -277,7 +277,7 @@
border-radius: 99rem;
}
.btn:not(.popup__btn, .btn--plain, .btn--reversed) {
.btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) {
--btn-background: var(--card-color);
--btn-color: var(--color-ink-inverted);
}
@@ -299,6 +299,19 @@
}
}
.card-perma__notch-new-card-buttons {
display: flex;
gap: var(--inline-space-half);
@media (max-width: 479px) {
flex-direction: column;
.btn {
inline-size: 100%;
}
}
}
.card-perma__closure-message {
color: var(--card-color);
}
@@ -23,6 +23,7 @@
}
.comment {
display: flex;
margin-inline: auto;
max-inline-size: var(--comment-max);
position: relative;
@@ -34,6 +35,56 @@
.house-md-content {
padding: var(--comment-padding-block) 0 0;
}
.comment-by-system & {
--comment-padding-block: var(--block-space-half);
text-align: center;
&::before {
/* Make up space for lack of avatar */
content: "";
display: flex;
inline-size: calc(var(--comment-padding-inline) * 0.9);
}
.comment__avatar {
display: none;
}
.comment__author {
a { margin: 0 auto; }
h3 { margin-inline: auto; }
strong { display: none; }
}
.comment__body {
padding: 0;
text-align: center;
}
.comment__content {
--stripe-color: var(--color-ink-lightest);
background-image: repeating-linear-gradient(
45deg in srgb,
var(--color-canvas) 0 1px,
var(--stripe-color) 1px 10px);
padding-inline: var(--comment-padding-inline);
.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 {
display: none !important;
}
}
}
.comment__author {
@@ -73,6 +124,7 @@
background-color: var(--comment-bg-color);
border-radius: 0.2em;
max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75));
padding:
var(--comment-padding-block)
calc(var(--comment-padding-inline) / 2)
@@ -85,53 +137,36 @@
background-color: var(--color-ink-lightest);
}
.comment--system {
--comment-padding-block: var(--block-space-half);
.comment__history {
background-color: var(--color-ink-lightest);
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);
position: absolute;
}
max-inline-size: var(--comment-max);
text-align: center;
.comment-by-system {
display: none;
transition: var(--dialog-duration) allow-discrete;
transition-property: display;
&::before {
/* Make up space for lack of avatar */
content: "";
display: flex;
inline-size: calc(var(--comment-padding-inline) * 0.75);
.comments--system-expanded & {
display: contents;
}
}
.comment__avatar {
/* Show the last system comment */
:nth-last-child(1 of .comment-by-system) {
display: contents;
.comment__history {
display: grid;
}
}
/* Hide the "Show history" button if there's only one system comment */
:nth-child(1 of .comment-by-system) {
.comment__history {
display: none;
}
.comment__author {
a {
margin: 0 auto;
}
h3 {
margin-inline: auto;
}
strong {
display: none;
}
}
.comment__body {
padding: 0;
text-align: center;
}
.comment__content {
background-image: repeating-linear-gradient(
45deg in srgb,
var(--color-canvas) 0 1px,
var(--color-ink-lightest) 1px 10px
);
padding-inline: var(--comment-padding-inline);
}
.reactions {
display: none !important;
}
}
}
@@ -221,7 +221,6 @@
background-color: var(--color-canvas);
grid-row-start: 1;
inset-block-start: calc(var(--block-space) * -1);
margin-inline: calc(var(--main-padding) * -1);
margin-block-end: var(--events-gap);
padding-block: calc(var(--events-gap) * 3) var(--events-gap);
position: sticky;
@@ -39,7 +39,6 @@
.header__actions {
display: flex;
align-items: center;
font-size: var(--text-x-small);
gap: var(--header-gap);
inline-size: var(--header-actions-width);
@@ -117,15 +116,17 @@
/* Optional class to stack header actions on small screens
/* ------------------------------------------------------------------------ */
.header--mobile-actions-stack {
/* .header--mobile-actions-stack {
@media (max-width: 639px) {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"actions-start menu actions-end"
"title title title";
"menu menu"
"actions-start actions-end"
"title title";
.header__title {
margin-block-start: 0.25rem;
}
}
}
} */
}
+1
View File
@@ -59,6 +59,7 @@
.icon--fizzy { --svg: url("fizzy.svg"); }
.icon--globe { --svg: url("globe.svg "); }
.icon--golden-ticket { --svg: url("golden-ticket.svg "); }
.icon--history { --svg: url("history.svg "); }
.icon--home { --svg: url("home.svg "); }
.icon--install-edge { --svg: url("install-edge.svg "); }
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
+7
View File
@@ -0,0 +1,7 @@
@layer platform {
[data-platform~=ios] {
.hide-on-ios {
display: none;
}
}
}
-5
View File
@@ -1,5 +0,0 @@
@layer ios {
.hide-in-ios-app {
display: none;
}
}
@@ -3,7 +3,7 @@
--opacity: 0.5;
--size: 0.8em;
background: url("/link.svg") no-repeat center bottom 0.2em;
background: url(link.svg) no-repeat center bottom 0.2em;
background-size: var(--size);
block-size: 1em;
color: var(--color-link);
@@ -1,9 +0,0 @@
@layer mobile_app {
.hide-in-mobile-app {
display: none;
}
.show-in-mobile-app {
display: unset;
}
}
@@ -1,10 +1,13 @@
@layer mobile_app {
/* Use custom insets to account for floating navigation elements.
The mobile apps can inject their own inset value based on native elements on screen. */
:root {
@layer native {
[data-platform~=native] {
/* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */
--custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px));
--custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px));
--custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
--custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px));
.hide-on-native {
display: none;
}
}
}
@@ -36,6 +36,15 @@
}
}
.popup__footer {
border-block-start: 1px solid var(--color-ink-lightest);
color: var(--card-color);
margin-block-start: var(--popup-item-padding-inline);
padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0;
text-align: center;
text-transform: initial;
}
.popup__title {
font-weight: 800;
white-space: nowrap;
@@ -24,6 +24,10 @@
margin: 0;
position: absolute;
@media (max-width: 640px) {
inset-inline-end: calc(var(--comment-padding-inline) / 3);
}
.reactions__list {
display: none;
}
@@ -28,6 +28,10 @@
}
}
p:has(+ p) {
margin: 0;
}
ol, ul {
padding-inline-start: 3ch;
}
@@ -93,17 +93,14 @@
}
.steps__icon {
--icon-size: 0.875em;
background-color: var(--card-color);
block-size: 1.3em;
border-radius: 50%;
color: var(--color-ink-inverted);
display: grid;
inline-size: 1.3em;
place-content: center;
.icon {
background-color: var(--color-ink-inverted);
block-size: 0.8em;
inline-size: 0.8em;
}
}
}
}
@@ -424,6 +424,7 @@
.card__meta-text:not(.card__meta-text--updated),
.card__stages,
.card__steps,
.card__comments,
.card__closed {
display: none;
}
@@ -184,6 +184,23 @@
.fill-highlight { background-color: var(--color-highlight); }
.fill-transparent { background-color: transparent; }
.fill-highlighter {
display: inline-block;
position: relative;
z-index: 1;
&::before {
background-color: var(--color-highlight);
border-radius: 0.2em;
content: "";
inset-block: 0;
inset-inline: -0.1em;
position: absolute;
transform: skewX(-10deg) rotate(1deg);
z-index: -1;
}
}
.translucent { opacity: var(--opacity, 0.66); }
/* Borders */
@@ -252,8 +269,10 @@
}
}
.show-in-mobile-app {
display: none;
.show-on-native {
body:not([data-platform~=native]) & {
display: none;
}
}
.hide-scrollbar {
-108
View File
@@ -1,108 +0,0 @@
@layer components {
.icon {
-webkit-touch-callout: none;
background-color: currentColor;
block-size: var(--icon-size, 1em);
display: inline-block;
flex-shrink: 0;
inline-size: var(--icon-size, 1em);
mask-image: var(--svg);
mask-position: center;
mask-repeat: no-repeat;
mask-size: var(--icon-size, 1em);
pointer-events: none;
user-select: none;
}
img.icon {
background: none;
}
.icon--37signals { --svg: url("/37signals.svg"); }
.icon--add { --svg: url("/add.svg"); }
.icon--add--meta { --svg: url("/add--meta.svg"); }
.icon--arrow-left { --svg: url("/arrow-left.svg"); }
.icon--arrow-right { --svg: url("/arrow-right.svg"); }
.icon--arrow-up { --svg: url("/arrow-up.svg"); }
.icon--art { --svg: url("/art.svg"); }
.icon--assigned { --svg: url("/assigned.svg"); }
.icon--attachment { --svg: url("/attachment.svg"); }
.icon--bell-alert { --svg: url("/bell-alert.svg"); }
.icon--bell-off { --svg: url("/bell-off.svg"); }
.icon--bell { --svg: url("/bell.svg"); }
.icon--bolt { --svg: url("/bolt.svg"); }
.icon--bookmark-outline { --svg: url("/bookmark-outline.svg"); }
.icon--bookmark { --svg: url("/bookmark.svg"); }
.icon--boost { --svg: url("/boost.svg"); }
.icon--camera { --svg: url("/camera.svg"); }
.icon--caret-down { --svg: url("/caret-down.svg"); }
.icon--check { --svg: url("/check.svg"); }
.icon--check-circle { --svg: url("/check-circle.svg"); }
.icon--check-all { --svg: url("/check-all.svg"); }
.icon--clipboard { --svg: url("/clipboard.svg"); }
.icon--close { --svg: url("/close.svg"); }
.icon--close-circle { --svg: url("/close-circle.svg"); }
.icon--collapse { --svg: url("/collapse.svg"); }
.icon--board { --svg: url("/board.svg"); }
.icon--board-add { --svg: url("/board-add.svg"); }
.icon--column-left { --svg: url("/column-left.svg"); }
.icon--column-right { --svg: url("/column-right.svg"); }
.icon--comment { --svg: url("/comment.svg"); }
.icon--copy-paste { --svg: url("/copy-paste.svg"); }
.icon--crown { --svg: url("/crown.svg"); }
.icon--email { --svg: url("/email.svg"); }
.icon--everyone { --svg: url("/everyone.svg"); }
.icon--expand { --svg: url("/expand.svg"); }
.icon--gear { --svg: url("/gear.svg"); }
.icon--grid { --svg: url("/grid.svg"); }
.icon--filter { --svg: url("/filter.svg"); }
.icon--fizzy { --svg: url("/fizzy.svg"); }
.icon--globe { --svg: url("/globe.svg"); }
.icon--golden-ticket { --svg: url("/golden-ticket.svg"); }
.icon--home { --svg: url("/home.svg"); }
.icon--install { --svg: url("/install.svg"); }
.icon--install { --svg: url("/install.svg"); }
.icon--install-edge { --svg: url("/install-edge.svg"); }
.icon--lifebuoy { --svg: url("/lifebuoy.svg"); }
.icon--lock { --svg: url("/lock.svg"); }
.icon--logout { --svg: url("/logout.svg"); }
.icon--marker { --svg: url("/marker.svg"); }
.icon--maximize { --svg: url("/maximize.svg"); }
.icon--menu { --svg: url("/menu.svg"); }
.icon--menu-dots-horizontal { --svg: url("/menu-dots-horizontal.svg"); }
.icon--menu-dots-vertical { --svg: url("/menu-dots-vertical.svg"); }
.icon--minus { --svg: url("/minus.svg"); }
.icon--move { --svg: url("/move.svg"); }
.icon--notification-bell-access-only { --svg: url("/bell.svg"); }
.icon--notification-bell-watching { --svg: url("/bell-off.svg"); }
.icon--notification-bell-reverse-access-only { --svg: url("/bell-off.svg"); }
.icon--notification-bell-reverse-watching { --svg: url("/bell.svg"); }
.icon--password { --svg: url("/password.svg"); }
.icon--pencil { --svg: url("/pencil.svg"); }
.icon--person { --svg: url("/person.svg"); }
.icon--person-add { --svg: url("/person-add.svg"); }
.icon--picture-add { --svg: url("/picture-add.svg"); }
.icon--picture-double { --svg: url("/picture-double.svg"); }
.icon--picture-remove { --svg: url("/picture-remove.svg"); }
.icon--picture-zoom { --svg: url("/picture-zoom.svg"); }
.icon--pinned { --svg: url("/pinned.svg"); }
.icon--qr-code { --svg: url("/qr-code.svg"); }
.icon--reaction { --svg: url("/reaction.svg"); }
.icon--refresh { --svg: url("/refresh.svg"); }
.icon--refresh--meta { --svg: url("/refresh--meta.svg"); }
.icon--remove { --svg: url("/remove.svg"); }
.icon--rename { --svg: url("/rename.svg"); }
.icon--search { --svg: url("/search.svg"); }
.icon--settings { --svg: url("/settings.svg"); }
.icon--share { --svg: url("/share.svg"); }
.icon--sliders { --svg: url("/sliders.svg"); }
.icon--switch { --svg: url("/switch.svg"); }
.icon--tag { --svg: url("/tag.svg"); }
.icon--tag-outline { --svg: url("/tag-outline.svg"); }
.icon--thumb-up { --svg: url("/thumb-up.svg"); }
.icon--trash { --svg: url("/trash.svg"); }
.icon--unpinned { --svg: url("/unpinned.svg"); }
.icon--unseen { --svg: url("/unseen.svg"); }
.icon--world { --svg: url("/world.svg"); }
.icon--youtube { --svg: url("/youtube.svg"); }
}
+27
View File
@@ -0,0 +1,27 @@
@layer components {
.welcome-letter {
position: relative;
view-transition-name: welcome-letter;
z-index: var(--z-welcome);
h2, p {
text-wrap: pretty;
}
}
.welcome-letter__close {
inset: var(--block-space) var(--block-space) auto auto;
position: absolute;
}
.welcome-letter__signature {
background-color: currentColor;
block-size: 3em;
display: inline-block;
inline-size: 8em;
mask-image: url("jf-signature.svg");
mask-position: center;
mask-repeat: no-repeat;
mask-size: 8em 3em;
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ class BoardsController < ApplicationController
def show_columns
cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded
set_page_and_extract_portion_from cards
fresh_when etag: [ @board, @page.records, @user_filtering ]
fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ]
end
def board_params
@@ -8,11 +8,16 @@ class Cards::AssignmentsController < ApplicationController
end
def create
@card.toggle_assignment @board.users.active.find(params[:assignee_id])
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
if @card.toggle_assignment @board.users.active.find(params[:assignee_id])
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
else
respond_to do |format|
format.turbo_stream
format.json { head :unprocessable_entity }
end
end
end
end
+1 -1
View File
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ])
params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ])
end
end
+3 -32
View File
@@ -4,13 +4,12 @@ module Authentication
included do
before_action :require_account # Checking and setting account must happen first
before_action :require_authentication
after_action :ensure_development_magic_link_not_leaked
helper_method :authenticated?
helper_method :email_address_pending_authentication
etag { Current.identity.id if authenticated? }
include LoginHelper
include Authentication::ViaMagicLink, LoginHelper
end
class_methods do
@@ -102,35 +101,7 @@ module Authentication
cookies.delete(:session_token)
end
def ensure_development_magic_link_not_leaked
unless Rails.env.development?
raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
end
end
def email_address_pending_authentication_matches?(email_address)
if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "")
session.delete(:email_address_pending_authentication)
true
else
false
end
end
def email_address_pending_authentication
session[:email_address_pending_authentication]
end
def redirect_to_session_magic_link(magic_link, return_to: nil)
serve_development_magic_link(magic_link)
session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link
session[:return_to_after_authenticating] = return_to if return_to
redirect_to main_app.session_magic_link_path(script_name: nil)
end
def serve_development_magic_link(magic_link)
if Rails.env.development?
flash[:magic_link_code] = magic_link&.code
end
def session_token
cookies[:session_token]
end
end
@@ -0,0 +1,67 @@
module Authentication::ViaMagicLink
extend ActiveSupport::Concern
included do
after_action :ensure_development_magic_link_not_leaked
end
private
def ensure_development_magic_link_not_leaked
unless Rails.env.development?
raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
end
end
def redirect_to_fake_session_magic_link(email_address, **options)
fake_magic_link = MagicLink.new(
identity: Identity.new(email_address: email_address),
code: SecureRandom.base32(6),
expires_at: MagicLink::EXPIRATION_TIME.from_now
)
redirect_to_session_magic_link fake_magic_link, **options
end
def redirect_to_session_magic_link(magic_link, return_to: nil)
serve_development_magic_link(magic_link)
set_pending_authentication_token(magic_link)
session[:return_to_after_authenticating] = return_to if return_to
respond_to do |format|
format.html { redirect_to main_app.session_magic_link_url(script_name: nil) }
format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created }
end
end
def serve_development_magic_link(magic_link)
if Rails.env.development? && magic_link.present?
flash[:magic_link_code] = magic_link.code
response.set_header("X-Magic-Link-Code", magic_link.code)
end
end
def set_pending_authentication_token(magic_link)
cookies[:pending_authentication_token] = {
value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at),
httponly: true,
same_site: :lax,
expires: magic_link.expires_at
}
end
def email_address_pending_authentication
pending_authentication_token_verifier.verified(pending_authentication_token)
end
def pending_authentication_token_verifier
Rails.application.message_verifier(:pending_authentication)
end
def pending_authentication_token
cookies[:pending_authentication_token]
end
def clear_pending_authentication_token
cookies.delete(:pending_authentication_token)
end
end
+1 -1
View File
@@ -16,7 +16,7 @@ module FilterScoped
end
def filter_params
params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)
params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)
end
def set_user_filtering
+2
View File
@@ -1,5 +1,7 @@
class LandingsController < ApplicationController
def show
flash.keep(:welcome_letter)
if Current.user.boards.one?
redirect_to board_path(Current.user.boards.first)
else
+2 -1
View File
@@ -4,7 +4,8 @@ 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
fresh_when etag: [ @filters, @boards, @tags, @users ]
fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ]
end
end
@@ -1,7 +1,7 @@
class Sessions::MagicLinksController < ApplicationController
disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" }
rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded
before_action :ensure_that_email_address_pending_authentication_exists
layout "public"
@@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController
def create
if magic_link = MagicLink.consume(code)
authenticate_with magic_link
authenticate magic_link
else
redirect_to session_magic_link_path, flash: { shake: true }
invalid_code
end
end
private
def ensure_that_email_address_pending_authentication_exists
unless email_address_pending_authentication.present?
redirect_to new_session_path, alert: "Enter your email address to sign in."
end
end
def authenticate_with(magic_link)
if email_address_pending_authentication_matches?(magic_link.identity.email_address)
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
else
redirect_to new_session_path, alert: "Authentication failed. Please try again."
alert_message = "Enter your email address to sign in."
respond_to do |format|
format.html { redirect_to new_session_path, alert: alert_message }
format.json { render json: { message: alert_message }, status: :unauthorized }
end
end
end
@@ -37,6 +32,41 @@ class Sessions::MagicLinksController < ApplicationController
params.expect(:code)
end
def authenticate(magic_link)
if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address)
sign_in magic_link
else
email_address_mismatch
end
end
def sign_in(magic_link)
clear_pending_authentication_token
start_new_session_for magic_link.identity
respond_to do |format|
format.html { redirect_to after_sign_in_url(magic_link) }
format.json { render json: { session_token: session_token } }
end
end
def email_address_mismatch
clear_pending_authentication_token
alert_message = "Something went wrong. Please try again."
respond_to do |format|
format.html { redirect_to new_session_path, alert: alert_message }
format.json { render json: { message: alert_message }, status: :unauthorized }
end
end
def invalid_code
respond_to do |format|
format.html { redirect_to session_magic_link_path, flash: { shake: true } }
format.json { render json: { message: "Try another code." }, status: :unauthorized }
end
end
def after_sign_in_url(magic_link)
if magic_link.for_sign_up?
new_signup_completion_path
@@ -44,4 +74,12 @@ class Sessions::MagicLinksController < ApplicationController
after_authentication_url
end
end
def rate_limit_exceeded
rate_limit_exceeded_message = "Try again in 15 minutes."
respond_to do |format|
format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message }
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
end
end
end
+42 -10
View File
@@ -1,7 +1,7 @@
class SessionsController < ApplicationController
disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
layout "public"
@@ -9,16 +9,12 @@ class SessionsController < ApplicationController
end
def create
if identity = Identity.find_by_email_address(email_address)
redirect_to_session_magic_link identity.send_magic_link
if identity = Identity.find_by(email_address: email_address)
sign_in identity
elsif Account.accepting_signups?
sign_up
else
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
magic_link = signup.create_identity if Account.accepting_signups?
redirect_to_session_magic_link magic_link
else
head :unprocessable_entity
end
redirect_to_fake_session_magic_link email_address
end
end
@@ -28,7 +24,43 @@ class SessionsController < ApplicationController
end
private
def magic_link_from_sign_in_or_sign_up
if identity = Identity.find_by_email_address(email_address)
identity.send_magic_link
else
signup = Signup.new(email_address: email_address)
signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups?
end
end
def email_address
params.expect(:email_address)
end
def rate_limit_exceeded
rate_limit_exceeded_message = "Try again later."
respond_to do |format|
format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
end
end
def sign_in(identity)
redirect_to_session_magic_link identity.send_magic_link
end
def sign_up
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
magic_link = signup.create_identity
redirect_to_session_magic_link magic_link
else
respond_to do |format|
format.html { redirect_to new_session_path, alert: "Something went wrong" }
format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity }
end
end
end
end
@@ -11,6 +11,7 @@ class Signups::CompletionsController < ApplicationController
@signup = Signup.new(signup_params)
if @signup.complete
flash[:welcome_letter] = true
redirect_to landing_url(script_name: @signup.account.slug)
else
render :new, status: :unprocessable_entity
-10
View File
@@ -1,8 +1,4 @@
module ApplicationHelper
def stylesheet_link_tags
stylesheet_link_tag *platform.stylesheet_paths, "data-turbo-track": "reload"
end
def page_title_tag
account_name = if Current.account && Current.session&.identity&.users&.many?
Current.account&.name
@@ -14,12 +10,6 @@ module ApplicationHelper
tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options
end
def inline_svg(name)
file_path = "#{Rails.root}/app/assets/images/#{name}.svg"
return File.read(file_path).html_safe if File.exist?(file_path)
"(not found)"
end
def back_link_to(label, url, action, **options)
link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do
icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe
+1 -1
View File
@@ -43,7 +43,7 @@ module ColumnsHelper
end
def column_frame_tag(id, src: nil, data: {}, **options, &block)
data = data.reverse_merge \
data = data.with_defaults \
drag_and_drop_refresh: true,
controller: "frame",
action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload"
+3 -1
View File
@@ -3,6 +3,8 @@ module MessagesHelper
turbo_frame_tag dom_id(card, :messages),
class: "comments gap center",
style: "--card-color: #{card.color}",
role: "group", aria: { label: "Messages" }, &
role: "group",
aria: { label: "Messages" },
data: { controller: "toggle-class", toggle_class_toggle_class: "comments--system-expanded" }, &
end
end
@@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { limit: Number, count: Number }
static targets = ["unassigned", "limitMessage"]
connect() {
this.updateState()
}
countValueChanged() {
this.updateState()
}
updateState() {
const atLimit = this.countValue >= this.limitValue
this.unassignedTargets.forEach(el => {
el.hidden = atLimit
})
if (this.hasLimitMessageTarget) {
this.limitMessageTarget.hidden = !atLimit
}
}
}
@@ -5,11 +5,13 @@ export default class extends Controller {
static targets = [ "dialog" ]
static values = {
modal: { type: Boolean, default: false },
sizing: { type: Boolean, default: true }
sizing: { type: Boolean, default: true },
autoOpen: { type: Boolean, default: false }
}
connect() {
this.dialogTarget.setAttribute("aria-hidden", "true")
if (this.autoOpenValue) this.open()
}
open() {
+5 -1
View File
@@ -1,9 +1,13 @@
class Storage::ReconcileJob < ApplicationJob
class ReconcileAborted < StandardError; end
queue_as :backend
limits_concurrency to: 1, key: ->(owner) { owner }
discard_on ActiveJob::DeserializationError
retry_on ReconcileAborted, wait: 1.minute, attempts: 3
def perform(owner)
owner.reconcile_storage
raise ReconcileAborted, "Could not get stable snapshot for #{owner.class}##{owner.id}" unless owner.reconcile_storage
end
end
+1 -1
View File
@@ -19,7 +19,7 @@ class Account < ApplicationRecord
def create_with_owner(account:, owner:)
create!(**account).tap do |account|
account.users.create!(role: :system, name: "System")
account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current))
account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current))
end
end
end
+1
View File
@@ -69,6 +69,7 @@ class Account::Seeder
<li>Make another called "Working on"</li>
</ol>
<p>Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.</p>
<p><br></p>
<p>After that, drag this card to “DONE” or select “DONE” in the sidebar.</p>
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/make-columns.gif" alt="Demo of adding columns" caption="Make two more columns" content-type="image/*" filename="make-columns.gif" presentation="gallery"></action-text-attachment>
HTML
+2
View File
@@ -0,0 +1,2 @@
module Admin
end
+14 -39
View File
@@ -1,6 +1,4 @@
class ApplicationPlatform < PlatformAgent
SCOPED_STYLESHEET_PATHS = {}
def ios?
match? /iPhone|iPad/
end
@@ -37,20 +35,24 @@ class ApplicationPlatform < PlatformAgent
!mobile?
end
def native?
match? /Hotwire Native/
end
def windows?
operating_system == "Windows"
end
def ios_app?
match? /Fizzy iOS/
end
def android_app?
match? /Fizzy Android/
end
def mobile_app?
ios_app? || android_app?
def type
if native? && android?
"native android"
elsif native? && ios?
"native ios"
elsif mobile?
"mobile web"
else
"desktop web"
end
end
def operating_system
@@ -65,31 +67,4 @@ class ApplicationPlatform < PlatformAgent
os =~ /Linux/ ? "Linux" : os
end
end
def stylesheet_paths
scoped_stylesheet_paths("web") +
(mobile_app? ? scoped_stylesheet_paths("mobile_app") : []) +
scoped_stylesheet_paths(stylesheet_asset_name)
end
private
def stylesheet_asset_name
case
when android_app? then "android"
when ios_app? then "ios"
else "desktop"
end
end
def scoped_stylesheet_paths(scope = css_asset_name)
# Allow new stylesheets to be added in dev/test without restarting server
SCOPED_STYLESHEET_PATHS.clear if Rails.env.development?
SCOPED_STYLESHEET_PATHS[scope] ||=
Rails.root.join("app/assets/stylesheets").then do |stylesheet_root|
stylesheet_root.glob("#{scope}/**/*.css").collect do |path|
path.to_s.remove(stylesheet_root.to_s + "/", ".css")
end
end
end
end
+11
View File
@@ -1,7 +1,18 @@
class Assignment < ApplicationRecord
LIMIT = 100
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :assignee, class_name: "User"
belongs_to :assigner, class_name: "User"
validate :within_limit, on: :create
private
def within_limit
if card.assignments.count >= LIMIT
errors.add(:base, "Card already has the maximum of #{LIMIT} assignees")
end
end
end
+24 -8
View File
@@ -12,25 +12,32 @@ module Board::Storage
# Calculate actual storage by summing blob sizes.
#
# Uses batched pluck queries to avoid loading huge ID arrays, and avoids
# ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool
# issues when ActiveStorage uses separate connection pools (e.g., with replicas).
# Storage tracking is a business abstraction - we count what users upload.
# Original upload bytes only; variants/previews/derivatives excluded.
# Physical storage optimizations (deduplication, compression) don't affect quotas.
def calculate_real_storage_bytes
@card_ids = nil # Clear memoization for fresh calculation
card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes
end
def card_ids
@card_ids ||= cards.pluck(:id)
end
def card_image_bytes
sum_blob_bytes_in_batches \
ActiveStorage::Attachment.where(record_type: "Card", name: "image"),
cards.pluck(:id)
card_ids
end
def card_embed_bytes
sum_embed_bytes_for "Card", cards.pluck(:id)
sum_embed_bytes_for "Card", card_ids
end
def comment_embed_bytes
sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id)
card_ids.each_slice(BATCH_SIZE).sum do |batch|
sum_embed_bytes_for "Comment", Comment.where(card_id: batch).pluck(:id)
end
end
def board_embed_bytes
@@ -48,9 +55,18 @@ module Board::Storage
end
def sum_blob_bytes_in_batches(base_scope, record_ids)
# Count per-attachment to match ledger model.
# Same blob attached 3 times = 3x bytes (business abstraction, not physical storage).
#
# Do NOT remove the join thinking it's a performance optimization - it's required
# for correct per-attachment counting. We keep ActiveStorage/ActionText in the same
# database (realm/geo partitioning, not functionality partitioning), so cross-table
# joins are fine.
record_ids.each_slice(BATCH_SIZE).sum do |batch_ids|
blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id)
ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size)
base_scope
.where(record_id: batch_ids)
.joins(:blob)
.sum("active_storage_blobs.byte_size")
end
end
end
+49
View File
@@ -68,6 +68,55 @@ class Card < ApplicationRecord
end
private
STORAGE_BATCH_SIZE = 1000
# Override to include comments, but only load comments that have attachments.
# Cards can have thousands of comments; most won't have attachments.
# Streams in batches to avoid loading all IDs into memory at once.
def storage_transfer_records
comment_ids_with_attachments = storage_comment_ids_with_attachments
if comment_ids_with_attachments.any?
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
else
[ self ]
end
end
def storage_comment_ids_with_attachments
direct = []
rich_text_map = {}
# Stream comment IDs in batches to avoid loading all into memory
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
batch_ids = batch.pluck(:id)
direct.concat \
ActiveStorage::Attachment
.where(record_type: "Comment", record_id: batch_ids)
.distinct
.pluck(:record_id)
ActionText::RichText
.where(record_type: "Comment", record_id: batch_ids)
.pluck(:id, :record_id)
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
end
embed_comment_ids = if rich_text_map.any?
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
ActiveStorage::Attachment
.where(record_type: "ActionText::RichText", record_id: batch_ids)
.distinct
.pluck(:record_id)
end.filter_map { |rt_id| rich_text_map[rt_id] }
else
[]
end
(direct + embed_comment_ids).uniq
end
def set_default_title
self.title = "Untitled" if title.blank?
end
+5 -3
View File
@@ -24,10 +24,12 @@ module Card::Assignable
private
def assign(user)
assignments.create! assignee: user, assigner: Current.user
watch_by user
assignment = assignments.create assignee: user, assigner: Current.user
track_event :assigned, assignee_ids: [ user.id ]
if assignment.persisted?
watch_by user
track_event :assigned, assignee_ids: [ user.id ]
end
rescue ActiveRecord::RecordNotUnique
# Already assigned
end
-2
View File
@@ -6,8 +6,6 @@ module Card::Statuses
before_save :mark_if_just_published
after_create -> { track_event :published }, if: :published?
scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) }
end
attr_accessor :was_just_published
+33 -15
View File
@@ -19,7 +19,7 @@ module Storage::Totaled
# Exact: snapshot + pending entries
def bytes_used_exact
(storage_total || create_storage_total!).current_usage
create_or_find_storage_total.current_usage
end
def materialize_storage_later
@@ -28,7 +28,7 @@ module Storage::Totaled
# Materialize all pending entries into snapshot
def materialize_storage
total = storage_total || create_storage_total!
total = create_or_find_storage_total
total.with_lock do
latest_entry_id = storage_entries.maximum(:id)
@@ -44,26 +44,44 @@ module Storage::Totaled
end
# Reconcile ledger against actual attachment storage.
# Uses cursor to ensure consistency: captures max entry ID first, then calculates
# real bytes, then sums only entries up to that cursor. Concurrent uploads during
# calculation will have entries with IDs beyond the cursor, avoiding double-count.
#
# Uses two-cursor approach for consistency: capture cursor before AND after the
# scan. If they differ, entries were added during the scan and we can't get an
# accurate diff without risking double-counting or undercounting.
#
# Returns true if reconciled successfully, false if aborted due to concurrent
# writes. Caller (ReconcileJob) handles retries to avoid amplification.
def reconcile_storage
max_entry_id = storage_entries.maximum(:id)
cursor_before = storage_entries.maximum(:id)
real_bytes = calculate_real_storage_bytes
ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0
diff = real_bytes - ledger_bytes
cursor_after = storage_entries.maximum(:id)
if diff.nonzero?
Storage::Entry.record \
account: is_a?(Account) ? self : account,
board: is_a?(Board) ? self : nil,
recordable: nil,
delta: diff,
operation: "reconcile"
if cursor_before != cursor_after
Rails.logger.warn "[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan"
false
else
ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0
diff = real_bytes - ledger_bytes
if diff.nonzero?
Rails.logger.info "[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes"
Storage::Entry.record \
account: is_a?(Account) ? self : account,
board: is_a?(Board) ? self : nil,
recordable: nil,
delta: diff,
operation: "reconcile"
end
true
end
end
private
def create_or_find_storage_total
self.storage_total ||= Storage::Total.create_or_find_by!(owner: self)
end
def calculate_real_storage_bytes
raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes"
end
+52 -12
View File
@@ -1,3 +1,6 @@
# Storage tracking is a business abstraction - we count what users upload.
# Original upload bytes only; variants/previews/derivatives excluded.
# Physical storage optimizations (deduplication, compression) don't affect quotas.
module Storage::Tracked
extend ActiveSupport::Concern
@@ -22,21 +25,27 @@ module Storage::Tracked
private
def board_transfer?
respond_to?(:board_id_changed?) && board_id_changed?
respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id?
end
def track_board_transfer
old_board_id = board_id_was
current_bytes = storage_bytes
old_board = Board.find_by(id: attribute_in_database(:board_id))
records = storage_transfer_records.compact
return if records.empty?
attachments_by_record = storage_attachments_for_records(records)
attachments_by_record.each do |recordable, attachments|
bytes = attachments.sum { |attachment| attachment.blob.byte_size }
next if bytes.zero?
if current_bytes.positive?
# Debit old board
if old_board_id
if old_board
Storage::Entry.record \
account: account,
board_id: old_board_id,
recordable: self,
delta: -current_bytes,
board: old_board,
recordable: recordable,
delta: -bytes,
operation: "transfer_out"
end
@@ -44,14 +53,45 @@ module Storage::Tracked
Storage::Entry.record \
account: account,
board: board,
recordable: self,
delta: current_bytes,
recordable: recordable,
delta: bytes,
operation: "transfer_in"
end
end
def storage_transfer_records
[ self ]
end
# Override if needed. Default = all direct attachments
def attachments_for_storage
ActiveStorage::Attachment.where(record: self)
def attachments_for_storage(recordable = self)
storage_attachments_for_records([ recordable ]).fetch(recordable, [])
end
def storage_attachments_for_records(recordables)
records = Array(recordables).compact
return {} if records.empty?
# Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents
records_by_key = records.index_by { |r| [ r.class.name, r.id ] }
rich_texts = ActionText::RichText.where(record: records)
rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] }
attachments = ActiveStorage::Attachment
.where(record: records + rich_texts)
.includes(:blob)
.to_a
attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped|
# Resolve parent without N+1: use lookup for RichText, direct for others
recordable = if attachment.record_type == "ActionText::RichText"
rich_text_to_parent[attachment.record_id]
else
records_by_key[[ attachment.record_type, attachment.record_id ]]
end
grouped[recordable] << attachment if recordable
end
end
end
+1 -1
View File
@@ -14,7 +14,7 @@ class Event::Description
end
def to_plain_text
to_sentence(creator_name, card.title)
to_sentence(creator_name, h(card.title))
end
private
+13
View File
@@ -2,4 +2,17 @@ module Storage
def self.table_name_prefix
"storage_"
end
# Record types that participate in storage tracking (ledger entries created on attach).
# The no-reuse validation uses this to scope its check.
#
# IMPORTANT: Update this constant when adding tracked attachments to new models.
# If you add a direct attachment (not via RichText embeds) to Comment, Board, or
# another model with Storage::Tracked, you must add its record_type here or the
# no-reuse validation won't protect it.
TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze
# Account ID for template/demo blobs that can be reused cross-tenant.
# Set to nil to disable the whitelist (no exemptions).
TEMPLATE_ACCOUNT_ID = nil
end
+9 -10
View File
@@ -26,24 +26,23 @@ module Storage::AttachmentTracking
return unless @storage_snapshot
Storage::Entry.record \
account_id: @storage_snapshot[:account_id],
board_id: @storage_snapshot[:board_id],
recordable_type: @storage_snapshot[:recordable_type],
recordable_id: @storage_snapshot[:recordable_id],
blob_id: @storage_snapshot[:blob_id],
account: @storage_snapshot[:account],
board: @storage_snapshot[:board],
recordable: @storage_snapshot[:recordable],
blob: blob,
delta: -blob.byte_size,
operation: "detach"
end
# Snapshot records in before_destroy since parent may be deleted by the time
# after_destroy_commit runs. The records may be destroyed but .id still works.
def snapshot_storage_context
return unless storage_tracked_record
@storage_snapshot = {
account_id: storage_tracked_record.account.id,
board_id: storage_tracked_record.board_for_storage_tracking&.id,
recordable_type: storage_tracked_record.class.name,
recordable_id: storage_tracked_record.id,
blob_id: blob.id
account: storage_tracked_record.account,
board: storage_tracked_record.board_for_storage_tracking,
recordable: storage_tracked_record
}
end

Some files were not shown because too many files have changed in this diff Show More