Merge branch 'main' into expand-activity-columns

This commit is contained in:
Jorge Manrubia
2025-11-21 10:25:39 +01:00
71 changed files with 1063 additions and 262 deletions
+142
View File
@@ -0,0 +1,142 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is Fizzy?
Fizzy is a collaborative project management and issue tracking application built by 37signals/Basecamp. It's a kanban-style tool for teams to create and manage cards (tasks/issues) across boards, organize work into columns representing workflow stages, and collaborate via comments, mentions, and assignments.
## Development Commands
### Setup and Server
```bash
bin/setup # Initial setup (installs gems, creates DB, loads schema)
bin/dev # Start development server (runs on port 3006)
```
Development URL: http://fizzy.localhost:3006
Login with: david@37signals.com (development fixtures), password will appear in the browser console
### Testing
```bash
bin/rails test # Run unit tests (fast)
bin/rails test test/path/file_test.rb # Run single test file
bin/rails test:system # Run system tests (Capybara + Selenium)
bin/ci # Run full CI suite (style, security, tests)
# For parallel test execution issues, use:
PARALLEL_WORKERS=1 bin/rails test
```
CI pipeline (`bin/ci`) runs:
1. Rubocop (style)
2. Bundler audit (gem security)
3. Importmap audit
4. Brakeman (security scan)
5. Application tests
6. System tests
### Database
```bash
bin/rails db:fixtures:load # Load fixture data
bin/rails db:migrate # Run migrations
bin/rails db:reset # Drop, create, and load schema
```
### Other Utilities
```bash
bin/rails dev:email # Toggle letter_opener for email preview
bin/jobs # Manage Solid Queue jobs
bin/kamal deploy # Deploy (requires 1Password CLI for secrets)
```
## Architecture Overview
### Multi-Tenancy (URL-Based)
Fizzy uses **URL path-based multi-tenancy**:
- Each Account (tenant) has a unique `external_account_id` (7+ digits)
- URLs are prefixed: `/{account_id}/boards/...`
- Middleware (`AccountSlug::Extractor`) extracts the account ID from the URL and sets `Current.account`
- The slug is moved from `PATH_INFO` to `SCRIPT_NAME`, making Rails think it's "mounted" at that path
- All models include `account_id` for data isolation
- Background jobs automatically serialize and restore account context
**Key insight**: This architecture allows multi-tenancy without subdomains or separate databases, making local development and testing simpler.
### Authentication & Authorization
**Passwordless magic link authentication**:
- Global `Identity` (email-based) can have `Users` in multiple Accounts
- Users belong to an Account and have roles: admin, member, system
- Sessions managed via signed cookies
- Board-level access control via `Access` records
### Core Domain Models
**Account** → The tenant/organization
- Has users, boards, cards, tags, webhooks
- Has entropy configuration for auto-postponement
**Identity** → Global user (email)
- Can have Users in multiple Accounts
- Session management tied to Identity
**User** → Account membership
- Belongs to Account and Identity
- Has role (admin/member/system)
- Board access via explicit `Access` records
**Board** → Primary organizational unit
- Has columns for workflow stages
- Can be "all access" or selective
- Can be published publicly with shareable key
**Card** → Main work item (task/issue)
- Sequential number within each Account
- Rich text description and attachments
- Lifecycle: triage → columns → closed/not_now
- Automatically postpones after inactivity ("entropy")
**Event** → Records all significant actions
- Polymorphic association to changed object
- Drives activity timeline, notifications, webhooks
- Has JSON `particulars` for action-specific data
### Entropy System
Cards automatically "postpone" (move to "not now") after inactivity:
- Account-level default entropy period
- Board-level entropy override
- Prevents endless todo lists from accumulating
- Configurable via Account/Board settings
### UUID Primary Keys
All tables use UUIDs (UUIDv7 format, base36-encoded as 25-char strings):
- Custom fixture UUID generation maintains deterministic ordering for tests
- Fixtures are always "older" than runtime records
- `.first`/`.last` work correctly in tests
### Background Jobs (Solid Queue)
Database-backed job queue (no Redis):
- Custom `FizzyActiveJobExtensions` prepended to ActiveJob
- Jobs automatically capture/restore `Current.account`
- Mission Control::Jobs for monitoring
Key recurring tasks (via `config/recurring.yml`):
- Deliver bundled notifications (every 30 min)
- Auto-postpone stale cards (hourly)
- Cleanup jobs for expired links, deliveries
### Sharded Full-Text Search
16-shard MySQL full-text search instead of Elasticsearch:
- Shards determined by account ID hash (CRC32)
- Search records denormalized for performance
- Models in `app/models/search/`
## Coding style
Please read the separate file `STYLE.md` for some guidance on coding style.
+1 -1
View File
@@ -14,7 +14,7 @@ And then run the development server:
bin/dev
You'll be able to access the app in development at http://development-tenant.fizzy.localhost:3006
You'll be able to access the app in development at http://fizzy.localhost:3006
## Running tests
+217
View File
@@ -0,0 +1,217 @@
# Style
We aim to write code that is a pleasure to read, and we have a lot of opinions about how to do it well. Writing great code is an essential part of our programming culture, and we deliberately set a high bar for every code change anyone contributes. We care about how code reads, how code looks, and how code makes you feel when you read it.
We love discussing code. If you have questions about how to write something, or if you detect some smell you are not quite sure how to solve, please ask away to other programmers. A Pull Request is a great way to do this.
When writing new code, unless you are very familiar with our approach, try to find similar code elsewhere to look for inspiration.
## Conditional returns
In general, we prefer to use expanded conditionals over guard clauses.
```ruby
# Bad
def todos_for_new_group
ids = params.require(:todolist)[:todo_ids]
return [] unless ids
@bucket.recordings.todos.find(ids.split(","))
end
# Good
def todos_for_new_group
if ids = params.require(:todolist)[:todo_ids]
@bucket.recordings.todos.find(ids.split(","))
else
[]
end
end
```
This is because guard clauses can be hard to read, especially when they are nested.
As an exception, we sometimes use guard clauses to return early from a method:
* When the return is right at the beginning of the method.
* When the main method body is not trivial and involves several lines of code.
```ruby
def after_recorded_as_commit(recording)
return if recording.parent.was_created?
if recording.was_created?
broadcast_new_column(recording)
else
broadcast_column_change(recording)
end
end
```
## Methods ordering
We order methods in classes in the following order:
1. `class` methods
2. `public` methods with `initialize` at the top.
3. `private` methods
## Invocation order
We order methods vertically based on their invocation order. This helps us to understand the flow of the code.
```ruby
class SomeClass
def some_method
method_1
method_2
end
private
def method_1
method_1_1
method_1_2
end
def method_1_1
# ...
end
def method_1_2
# ...
end
def method_2
method_2_1
method_2_2
end
def method_2_1
# ...
end
def method_2_2
# ...
end
end
```
## To bang or not to bang
Should I call a method `do_something` or `do_something!`?
As a general rule, we only use `!` for methods that have a correspondent counterpart without `!`. In particular, we dont use `!` to flag destructive actions. There are plenty of destructive methods in Ruby and Rails that do not end with `!`.
## Visibility modifiers
We don't add a newline under visibility modifiers, and we indent the content under them.
```ruby
class SomeClass
def some_method
# ...
end
private
def some_private_method_1
# ...
end
def some_private_method_2
# ...
end
end
```
If a module only has private methods, we mark it `private` at the top and add an extra new line after but don't indent.
```ruby
class SomeModule
private
def some_private_method
# ...
end
end
```
## CRUD operations from controllers
In general, we favor a vanilla Rails approach to CRUD operations. We create and update models from Rails controllers passing the parameters directly to the model constructor or update method. We do not use services or form objects to handle these operations.
There are exceptional scenarios where we need to perform more complex operations, and we use form objects or higher-level service methods to handle them. We use the same pattern for both creations and updates.
Related to this, we prefer to avoid [nested attributes](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html). If you find yourself wanting to use `accepts_nested_attributes_for`, that's a good smell that you might want to consider using a form object instead.
As an example, you can check how we create and update messages in HEY's: `MessagesController`:
```ruby
class MessagesController < ApplicationController
def create
@entry = Entry.enter \
new_message,
on: new_topic,
status: :drafted,
address: entry_addressed_param,
scheduled_delivery_at: entry_scheduled_delivery_at_param,
scheduled_bubble_up_on: entry_scheduled_bubble_up_on_param
respond_to_saved_entry @entry
end
def update
previously_scheduled = @entry.scheduled_delivery
@entry.revise \
message_params,
status: :drafted,
is_delivery_imminent: !entry_status_param.drafted?,
address: entry_addressed_param,
scheduled_delivery_at: entry_scheduled_delivery_at_param,
scheduled_bubble_up_on: entry_scheduled_bubble_up_on_param
respond_to_saved_entry(@entry, previously_scheduled: previously_scheduled)
end
end
class Entry < ApplicationRecord
def self.enter(*args, **kwargs)
Entry::Enter.new(*args, **kwargs).perform
end
def revise(*args, **kwargs)
Entry::Revise.new(self, *args, **kwargs).perform
end
end
```
## Run async operations in jobs
As a general rule, we write shallow job classes that delegate the logic itself to domain models:
* We typically use the suffix `_later` to flag methods that enqueue a job.
* A common scenario is having a model class that enqueues a job that, when executed, invokes some method in that same class. In this case, we use the suffix `_now` for the regular synchronous method.
```ruby
module Event::Relaying
extend ActiveSupport::Concern
included do
after_create_commit :relay_later
end
def relay_later
Event::RelayJob.perform_later(self)
end
def relay_now
# ...
end
end
class Event::RelayJob < ApplicationJob
def perform(event)
event.relay_now
end
end
```
+1 -9
View File
@@ -18,7 +18,6 @@
--progress-max-cards: 20;
--progress-max-height: 50dvh;
-ms-overflow-style: none; /* Hide x-scrollbar on Edge */
container-type: inline-size;
display: grid;
gap: var(--column-gap);
@@ -29,12 +28,6 @@
overflow-y: hidden;
padding-block-end: var(--column-width-collapsed);
position: relative;
scrollbar-width: none; /* Hide x-scrollbar on FF */
/* Hide x-scrollbar on Chrome/Safari/Opera */
&::-webkit-scrollbar {
display: none;
}
/* When it has something expanded */
&:has(.card-columns__left .cards:not(.is-collapsed), .card-columns__right .cards:not(.is-collapsed)) {
@@ -673,7 +666,6 @@
/* -------------------------------------------------------------------------- */
/* Surface a mini bubble if there are cards with bubbles inside */
.cards--considering:has(.bubble:not([hidden])),
.cards--doing.is-collapsed:has(.bubble:not([hidden])) {
.cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
@@ -690,7 +682,7 @@
inline-size: 1em;
inset: 0 0 auto auto;
position: absolute;
translate: 0 0;
translate: 20% -20%;
z-index: 1;
}
}
+4 -1
View File
@@ -49,8 +49,11 @@
}
}
/* Target mobile Safari only */
@supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) {
font-size: max(16px, 1em) !important;
@media (hover: none) {
font-size: max(16px, 1em) !important;
}
}
}
+10
View File
@@ -249,4 +249,14 @@
display: unset;
}
}
.hide-scrollbar {
-ms-overflow-style: none; /* Edge */
scrollbar-width: none; /* FF */
/* Chrome/Safari/Opera */
&::-webkit-scrollbar {
display: none;
}
}
}
+20
View File
@@ -0,0 +1,20 @@
class Admin::StatsController < AdminController
disallow_account_scope
layout "public"
def show
@accounts_total = Account.count
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
@identities_total = Identity.count
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
@identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count
@top_accounts = Account
.where("cards_count > 0")
.order(cards_count: :desc)
.limit(20)
end
end
+1 -1
View File
@@ -22,7 +22,7 @@ module Authorization
end
def ensure_staff
head :forbidden unless Current.user.staff?
head :forbidden unless Current.identity.staff?
end
def ensure_can_access_account
+6 -2
View File
@@ -29,10 +29,14 @@ class JoinCodesController < ApplicationController
private
def set_join_code
@join_code ||= Account::JoinCode.active.find_by(code: params.expect(:code), account: Current.account)
@join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account)
end
def ensure_join_code_is_valid
head :not_found unless @join_code&.active?
if @join_code.nil?
head :not_found
elsif !@join_code.active?
render :inactive, status: :gone
end
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class My::PinsController < ApplicationController
def index
@pins = Current.user.pins.includes(:card).ordered.limit(20)
fresh_when @pins
fresh_when etag: [ @pins, @pins.collect(&:card) ]
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class SessionsController < ApplicationController
# FIXME: Remove this before launch!
if Rails.env.remote?
unless Rails.env.local?
http_basic_authenticate_with \
name: Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.application.credentials.account_signup_http_basic_auth.password,
+4
View File
@@ -33,6 +33,10 @@ module CardsHelper
title.join(" ")
end
def card_drafted_or_added(card)
card.drafted? ? "Drafted" : "Added"
end
def card_social_tags(card)
tag.meta(property: "og:title", content: "#{card.title} | #{card.board.name}") +
tag.meta(property: "og:description", content: format_excerpt(@card&.description, length: 200)) +
+6 -3
View File
@@ -15,11 +15,14 @@ module FiltersHelper
user_filtering.selected_board_titles.collect { tag.strong it }.to_sentence.html_safe
end
def filter_place_menu_item(path, label, icon, new_window: false, current: false)
link_to_params = new_window ? { target: "_blank" } : {}
def filter_place_menu_item(path, label, icon, new_window: false, current: false, turbo: true)
link_to_params = {}
link_to_params.merge!({ target: "_blank" }) if new_window
link_to_params.merge!({ data: { turbo: false } }) unless turbo
tag.li class: "popup__item", id: "filter-place-#{label.parameterize}", data: { filter_target: "item", navigable_list_target: "item" }, aria: { checked: current } do
concat icon_tag(icon, class: "popup__icon")
concat(link_to(path, link_to_params.merge(class: "popup__btn btn")) do
concat(link_to(path, link_to_params.merge(class: "popup__btn btn"), data: { turbo: turbo }) do
concat tag.span(label, class: "overflow-ellipsis")
concat icon_tag("check", class: "checked flex-item-justify-end", "aria-hidden": true)
end)
+1 -1
View File
@@ -5,7 +5,7 @@ module HotkeysHelper
if key == "ctrl" && platform.mac?
""
elsif key == "enter"
""
platform.mac? ? "return" : "enter"
elsif key == "shift"
""
else
+13 -6
View File
@@ -7,15 +7,22 @@ module NotificationsHelper
end
def event_notification_body(event)
name = event.creator.name
creator = event.creator.name
case event_notification_action(event)
when "card_closed" then "Moved to Done by #{name}"
when "card_reopened" then "Reopened by #{name}"
when "card_published" then "Added by #{name}"
when "comment_created" then comment_notification_body(event)
when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}"
else name
when "card_unassigned" then "Unassigned by #{creator}"
when "card_published" then "Added by #{creator}"
when "card_closed" then "Moved to Done by #{creator}"
when "card_reopened" then "Reopened by #{creator}"
when "card_postponed" then "Moved to Not Now by #{creator}"
when "card_auto_postponed" then "Moved to Not Now due to inactivity"
when "card_title_changed" then "Renamed by #{creator}"
when "card_board_changed" then "Moved by #{creator}"
when "card_triaged" then "Moved to #{event.particulars.dig("particulars", "column")} by #{creator}"
when "card_sent_back_to_triage" then "Moved back to Maybe? by #{creator}"
when "comment_created" then comment_notification_body(event)
else creator
end
end
@@ -15,4 +15,8 @@ export default class extends Controller {
this.element.reload()
}
}
reload() {
this.element.reload()
}
}
+1 -6
View File
@@ -3,11 +3,6 @@ class MagicLinkMailer < ApplicationMailer
@magic_link = magic_link
@identity = @magic_link.identity
mail to: @identity.email_address, subject: "Your Fizzy verification code"
mail to: @identity.email_address, subject: "Your Fizzy code is #{ @magic_link.code }"
end
private
def default_url_options
Rails.application.config.action_mailer.default_url_options
end
end
+1 -1
View File
@@ -11,6 +11,6 @@ class Notification::BundleMailer < ApplicationMailer
mail \
to: bundle.user.identity.email_address,
subject: "Latest Activity in Fizzy"
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: Latest Activity"
end
end
+1 -5
View File
@@ -21,11 +21,7 @@ class Card::ActivitySpike::Detector
def register_activity_spike
Card.suppressing_turbo_broadcasts do
if card.activity_spike
card.activity_spike.touch
else
card.create_activity_spike!
end
Card::ActivitySpike.find_or_create_by!(card: card).touch
end
end
+1 -1
View File
@@ -20,7 +20,7 @@ module Card::Statuses
private
def update_created_at_on_publication
if will_save_change_to_status? && status_in_database.inquiry.drafted?
self.created_at = Time.now
self.created_at = Time.current
end
end
end
+1 -1
View File
@@ -36,7 +36,7 @@ class Filter < ApplicationRecord
result.mentioning(term, user: creator)
end
result
result.distinct
end
end
+5 -3
View File
@@ -38,11 +38,13 @@ class Notification::Bundle < ApplicationRecord
def deliver
user.in_time_zone do
processing!
Current.with_account(user.account) do
processing!
Notification::BundleMailer.notification(self).deliver if deliverable?
Notification::BundleMailer.notification(self).deliver if deliverable?
delivered!
delivered!
end
end
end
+2 -1
View File
@@ -14,7 +14,8 @@ class Notifier
def notify
if should_notify?
recipients.map do |recipient|
# Processing recipients in order avoids deadlocks if notifications overlap.
recipients.sort_by(&:id).map do |recipient|
Notification.create! user: recipient, source: source, creator: creator
end
end
+2 -1
View File
@@ -2,8 +2,9 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to account_join_code_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<%= link_to account_join_code_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<%= icon_tag "arrow-left" %>
<strong>Back to Invite link</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
</span>
+3 -2
View File
@@ -2,8 +2,9 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to account_settings_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<%= link_to account_settings_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<%= icon_tag "arrow-left" %>
<strong>Back to Account Settings</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
</span>
@@ -30,7 +31,7 @@
<% end %>
</div>
<div class="center flex align-center gap">
<div class="center flex flex-wrap justify-center align-center gap">
<%= tag.button class: "btn btn--link", data: {
controller: "copy-to-clipboard", action: "copy-to-clipboard#copy",
copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url } do %>
+95
View File
@@ -0,0 +1,95 @@
<% @page_title = "Account Statistics" %>
<% content_for :header do %>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<section class="settings">
<div class="settings__panel panel shadow center">
<div class="flex flex-column gap margin-block-half">
<div class="flex flex-column gap-half">
<header>
<h2 class="divider txt-medium margin-block-start">Accounts Created</h2>
</header>
<div class="flex gap-half">
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">Total</div>
<div class="txt-large">
<strong><%= @accounts_total %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">7 days</div>
<div class="txt-large">
<strong><%= @accounts_last_7_days %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">24 hours</div>
<div class="txt-large">
<strong><%= @accounts_last_24_hours %></strong>
</div>
</div>
</div>
</div>
<div class="flex flex-column gap-half">
<header>
<h2 class="divider txt-medium margin-block-start">Identities Created</h2>
</header>
<div class="flex gap-half">
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">Total</div>
<div class="txt-large">
<strong><%= @identities_total %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">7 days</div>
<div class="txt-large">
<strong><%= @identities_last_7_days %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">24 hours</div>
<div class="txt-large">
<strong><%= @identities_last_24_hours %></strong>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="settings__panel panel shadow center">
<header>
<h2 class="divider txt-medium margin-block-start">
Top 20 Accounts by Card Count
</h2>
</header>
<ul class="margin-block-half">
<% @top_accounts.each do |account| %>
<% admin_user = account.users.find { |u| u.role == "admin" } %>
<li class="flex align-start gap-half margin-block-start-half">
<div
class="flex-item-grow min-width overflow-ellipsis"
style="text-align: left"
>
<strong><%= account.name %></strong>
<br>
<span class="txt-x-small txt-ink-medium">
#<%= account.external_account_id %> •
<%= admin_user&.identity&.email_address || "No admin" %>
</span>
</div>
<div class="txt-medium">
<strong><%= number_with_delimiter(account.cards_count) %></strong>
<span class="txt-x-small txt-ink-medium">cards</span>
</div>
</li>
<% end %>
</ul>
</div>
</section>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= tag.div class: "card-columns", data: {
<%= tag.div class: "card-columns hide-scrollbar", data: {
controller: "collapsible-columns drag-and-drop drag-and-strum",
drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item",
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
-1
View File
@@ -18,7 +18,6 @@
<%= form.button class: "comment__submit btn btn--reversed",
data: { form_target: "submit" }, disabled: true do %>
<span>Post</span>
<kbd class="txt-x-small hide-on-touch"><%= hotkey_label([ "ctrl", "enter" ]) %></kbd>
<% end %>
</span>
<% end %>
+2 -2
View File
@@ -11,9 +11,9 @@
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
<%= general_prompts(@card.board) %>
<% end %>
<div class="flex gap-half justify-start">
<div class="flex gap-half justify-start align-center">
<%= form.button class: "btn btn--reversed", type: :submit do %>
<span>Save changes</span>
<span>Save</span>
<% end %>
<%= link_to card_comment_path(@card, @comment), class: "btn", data: { form_target: "cancel" } do %>
<span>Cancel</span>
+4 -2
View File
@@ -1,5 +1,6 @@
<% if card.published? %>
<%= turbo_frame_tag card, :edit, data: { turbo_permanent: true } do %>
<div data-turbo-permanent>
<%= turbo_frame_tag card, :edit do %>
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %>
</h1>
@@ -15,12 +16,13 @@
<kbd class="txt-x-small hide-on-touch">e</kbd>
<% end %>
<% end %>
</div>
<% else %>
<%= form_with model: card, id: "card_form", data: { controller: "autoresize auto-save" } do |form| %>
<h1 class="card__title">
<%= form.label :title, class: "flex flex-column align-center autoresize__wrapper", data: { autoresize_target: "wrapper", autoresize_clone_value: "" } do %>
<%= form.text_area :title, placeholder: "Name it…",
class: "card-field__title input input--textarea hide-focus-ring full-width borderless txt-align-start autoresize__textarea",
class: "card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar",
autofocus: card.title.blank?, rows: 1,
data: { autoresize_target: "textarea", action: "input->autoresize#resize auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent" } %>
<% end %>
@@ -3,7 +3,6 @@
form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
<span>Create card</span>
<kbd class="txt-x-small hide-on-touch"><%= hotkey_label([ "ctrl", "enter" ]) %></kbd>
<% end %>
<%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another",
@@ -4,7 +4,7 @@
</div>
<span class="card__meta-text card__meta-text--added">
Added <%= local_datetime_tag(card.created_at, style: :daysago) %>
<%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %>
</span>
<span class="card__meta-text card__meta-text--author">
@@ -4,8 +4,7 @@
</div>
<span class="card__meta-text card__meta-text--added">
<%= local_datetime_tag(card.created_at, style: :daysago) %>
<% "(Draft)" if card.drafted? %>
<%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %>
</span>
<span class="card__meta-text card__meta-text--author">
@@ -4,7 +4,7 @@
</div>
<span class="card__meta-text card__meta-text--added">
Added <%= local_datetime_tag(card.created_at, style: :daysago) %>
<%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %>
</span>
<span class="card__meta-text card__meta-text--author">
+1 -1
View File
@@ -3,7 +3,7 @@
data: { controller: "autoresize form local-save", local_save_key_value: "card-#{@card.id}", action: "turbo:submit-end->local-save#submit" } do |form| %>
<h1 class="card__title flex align-start gap-half">
<%= form.label :title, class: "flex flex-column align-center autoresize__wrapper", data: { autoresize_target: "wrapper", autoresize_clone_value: "" } do %>
<%= form.text_area :title, class: "card-field__title input input--textarea hide-focus-ring full-width borderless txt-align-start autoresize__textarea",
<%= form.text_area :title, class: "card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar",
required: true, autofocus: true, placeholder: "Name it…", rows: 1,
data: { autoresize_target: "textarea", action: "input->autoresize#resize keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" } %>
<% end %>
+15
View File
@@ -0,0 +1,15 @@
<% @page_title = "You can't join #{@join_code.account.name} right now." %>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<p class="txt-medium">This join code has no invitations left on it.</p>
<p class="txt-medium">
<%= link_to "Check out Fizzy", "https://www.fizzy.do" %>.
</p>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
@@ -1,7 +1,9 @@
<tr>
<td>
<h1 class="title">Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %></h1>
<p class="subtitle margin-block-end-double">You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.</p>
<p class="subtitle margin-block-end-double">
You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
</p>
<% @notifications.group_by(&:card).each do |card, notifications| %>
<h2 class="notification__board"><%= card.board.name %></h2>
@@ -1,5 +1,5 @@
Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>
You have <%= pluralize @notifications.count, "new notification" %>.
You have <%= pluralize @notifications.count, "new notification" %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
<% @notifications.group_by(&:card).each do |card, notifications| %>
--------------------------------------------------------------------------------
+9
View File
@@ -0,0 +1,9 @@
<% if accounts.many? %>
<% cache [ Current.identity, accounts, Current.account ] do %>
<%= collapsible_nav_section "Accounts" do %>
<% accounts.each do |account| %>
<%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %>
<% end %>
<% end %>
<% end %>
<% end %>
+12
View File
@@ -0,0 +1,12 @@
<%= collapsible_nav_section "Boards" do %>
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item">
<%= icon_tag "add", class: "popup__icon" %>
<%= link_to new_board_path, class: "popup__btn btn" do %>
<span class="overflow-ellipsis">Add a board</span>
<% end %>
</li>
<% boards.each do |board| %>
<%= my_menu_board_item(board) %>
<% end %>
<% end %>
+14
View File
@@ -0,0 +1,14 @@
<%= collapsible_nav_section "Custom views", id: "my-filters" do %>
<%= form_with url: cards_path, method: :get, data: { controller: "form" } do |form| %>
<li class="popup__item overflow-ellipsis" data-navigable-list-target="item" data-filter-target="item" id="filter-custom-create">
<%= icon_tag "bookmark", class: "popup__icon" %>
<%= link_to cards_path(expand_all: true), class: "popup__btn btn" do %>
<span>Create a custom view</span>
<% end %>
</li>
<% filters.each do |filter| %>
<%= my_menu_filter_item(filter) %>
<% end %>
<% end %>
<% end %>
+12
View File
@@ -0,0 +1,12 @@
<%= collapsible_nav_section "People" do %>
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item">
<%= icon_tag "add", class: "popup__icon" %>
<%= link_to account_join_code_path, class: "popup__btn btn" do %>
<span class="overflow-ellipsis">Invite people</span>
<% end %>
</li>
<% users.each do |user| %>
<%= my_menu_user_item(user) %>
<% end %>
<% end %>
+13
View File
@@ -0,0 +1,13 @@
<%= collapsible_nav_section "Settings" do %>
<%= filter_place_menu_item account_settings_path, "Account Settings", "settings" %>
<%= filter_place_menu_item user_path(Current.user), "My Profile", "person" %>
<%= filter_place_menu_item notifications_path, "All notifications", "bell" %>
<%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %>
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
<%= button_to session_url(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
<span>Sign out</span>
<% end %>
<% end %>
<% end %>
+5
View File
@@ -0,0 +1,5 @@
<%= collapsible_nav_section "Tags" do %>
<% tags.each do |tag| %>
<%= my_menu_tag_item(tag) %>
<% end %>
<% end %>
+6 -71
View File
@@ -1,76 +1,11 @@
<%= turbo_frame_tag "my_menu", target: "_top" do %>
<%= render "my/menus/jump" do %>
<%= collapsible_nav_section "Boards" do %>
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item">
<%= icon_tag "add", class: "popup__icon" %>
<%= link_to new_board_path, class: "popup__btn btn" do %>
<span class="overflow-ellipsis">Add a board</span>
<% end %>
</li>
<% @boards.each do |board| %>
<%= my_menu_board_item(board) %>
<% end %>
<% end %>
<%= collapsible_nav_section "Custom views", id: "my-filters" do %>
<%= form_with url: cards_path, method: :get, data: { controller: "form" } do |form| %>
<li class="popup__item overflow-ellipsis" data-navigable-list-target="item" data-filter-target="item" id="filter-custom-create">
<%= icon_tag "bookmark", class: "popup__icon" %>
<%= link_to cards_path(expand_all: true), class: "popup__btn btn" do %>
<span>Create a custom view</span>
<% end %>
</li>
<% @filters.each do |filter| %>
<%= my_menu_filter_item(filter) %>
<% end %>
<% end %>
<% end %>
<%= collapsible_nav_section "Tags" do %>
<% @tags.each do |tag| %>
<%= my_menu_tag_item(tag) %>
<% end %>
<% end %>
<%= collapsible_nav_section "People" do %>
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item">
<%= icon_tag "add", class: "popup__icon" %>
<%= link_to account_join_code_path, class: "popup__btn btn" do %>
<span class="overflow-ellipsis">Invite people</span>
<% end %>
</li>
<% @users.each do |user| %>
<%= my_menu_user_item(user) %>
<% end %>
<% end %>
<%= collapsible_nav_section "Settings" do %>
<%= filter_place_menu_item account_settings_path, "Account Settings", "settings" %>
<%= filter_place_menu_item user_path(Current.user), "My Profile", "person" %>
<%= filter_place_menu_item notifications_path, "All notifications", "bell" %>
<%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %>
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
<%= button_to session_url(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
<span>Sign out</span>
<% end %>
<% end %>
<% end %>
<% accounts = Current.identity.accounts %>
<% if accounts.many? %>
<% cache [ Current.identity, accounts ] do %>
<%= collapsible_nav_section "Accounts" do %>
<% accounts.each do |account| %>
<%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", new_window: true, current: account == Current.account %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= render "my/menus/boards", boards: @boards %>
<%= render "my/menus/custom_views", filters: @filters %>
<%= render "my/menus/tags", tags: @tags %>
<%= render "my/menus/people", users: @users %>
<%= render "my/menus/settings" %>
<%= render "my/menus/accounts", accounts: Current.identity.accounts %>
<% end %>
<footer class="nav__footer">
+7 -7
View File
@@ -2,13 +2,13 @@
<section class="tray tray--pins" data-controller="dialog">
<%= tag.dialog id: "pin-tray", class: "tray__dialog", data: {
action: "keydown->navigable-list#navigate dialog:show@document->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside",
controller: "navigable-list",
dialog_target: "dialog",
navigable_list_actionable_items_value: "true",
navigable_list_reverse_navigation_value: "true" },
turbo_permanent: true do %>
<%= turbo_frame_tag "pins", src: my_pins_path %>
action: "keydown->navigable-list#navigate dialog:show@document->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside",
controller: "navigable-list",
dialog_target: "dialog",
navigable_list_actionable_items_value: "true",
navigable_list_reverse_navigation_value: "true" },
turbo_permanent: true do %>
<%= turbo_frame_tag "pins", src: my_pins_path, data: { controller: "frame", action: "turbo:morph@document->frame#reload" } %>
<% end %>
<button class="tray__toggle" data-action="dialog#toggle keydown.p@document->hotkey#click" data-controller="hotkey" aria-label="Toggle pins stack" aria-haspopup="true">
@@ -5,5 +5,5 @@
</div>
<div class="card__notification-body overflow-ellipsis">
<%= event_notification_body(event) %></span>
<%= event_notification_body(event) %>
</div>
@@ -1,5 +1,5 @@
<%= turbo_frame_tag :cards_container do %>
<div class="card-columns" data-controller="collapsible-columns" data-collapsible-columns-board-value="<%= board.id %>" data-collapsible-columns-collapsed-class="is-collapsed" data-collapsible-columns-no-transitions-class="no-transitions">
<div class="card-columns hide-scrollbar" data-controller="collapsible-columns" data-collapsible-columns-board-value="<%= board.id %>" data-collapsible-columns-collapsed-class="is-collapsed" data-collapsible-columns-no-transitions-class="no-transitions">
<div class="card-columns__left">
<%= render "public/boards/show/not_now", board: board %>
+16 -20
View File
@@ -1,25 +1,21 @@
<li>
<%= link_to result.source, class: "search__result", data: { turbo_frame: "_top", action: "bar#reset" } do %>
<div>
<h3 class="search__title txt--medium margin-none">
# <%= result.card.number %> <%= result.card_title %>
</h3>
<div>
<h3 class="search__title txt--medium margin-none">
# <%= result.card.number %> <%= result.card_title %>
</h3>
<% if result.card_description.present? %>
<div class="search__excerpt">
<%= result.card_description %>
</div>
<% else
if result.comment_body.present? %>
<div class="search__excerpt search__excerpt--comment">
<%= avatar_preview_tag result.card.creator %>
<div>
<%= result.comment_body %>
</div>
</div>
<% end %>
<% end %>
</div>
<div class="overflow-ellipsis translucent txt-ink"><%= result.card.board.name %></div>
<% if result.comment.present? %>
<div class="search__excerpt search__excerpt--comment">
<%= avatar_preview_tag result.card.creator %>
<div><%= result.comment_body %></div>
</div>
<% elsif result.card_id.present? %>
<div class="search__excerpt"><%= result.card_description %></div>
<% end %>
</div>
<div class="overflow-ellipsis translucent txt-ink">
<%= result.card.board.name %>
</div>
<% end %>
</li>
+2 -1
View File
@@ -2,8 +2,9 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to user_path(@user), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<%= link_to user_path(@user), class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<%= icon_tag "arrow-left" %>
<strong>Back to profile</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
</span>
+2 -1
View File
@@ -2,8 +2,9 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to edit_user_path(@user, script_name: @user.account.slug), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<%= link_to edit_user_path(@user, script_name: @user.account.slug), class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<%= icon_tag "arrow-left" %>
<strong>Back to My profile</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
</span>
+2 -1
View File
@@ -2,8 +2,9 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to board_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<%= link_to board_webhooks_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<%= icon_tag "arrow-left" %>
<strong>Back to Webhooks</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
</span>
-19
View File
@@ -1,9 +1,5 @@
require_relative "boot"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module Fizzy
@@ -16,21 +12,6 @@ module Fizzy
# be reloaded or eager loaded.
config.autoload_lib ignore: %w[ assets tasks rails_ext ]
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# enable load_async
config.active_record.async_query_executor = :global_thread_pool
# include the tenant in query logs
config.active_record.query_log_tags_enabled = true
config.active_record.query_log_tags = [ :tenant ]
# Enable debug mode for Rails event logging so we get SQL query logs.
# This was made necessary by the change in https://github.com/rails/rails/pull/55900
config.after_initialize do
+1 -3
View File
@@ -1,8 +1,6 @@
default_options: &default_options
store_options:
# Cap age of oldest cache entry to fulfill retention policies
# max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
max_age: <%= 60.days.to_i %>
namespace: <%= Rails.env %>
default_connection: &default_connection
+1
View File
@@ -6,6 +6,7 @@ module FizzyActiveJobExtensions
prepended do
attr_reader :account
self.enqueue_after_transaction_commit = true
end
def initialize(...)
+1
View File
@@ -230,5 +230,6 @@ Rails.application.routes.draw do
namespace :admin do
mount MissionControl::Jobs::Engine, at: "/jobs"
get "stats", to: "stats#show"
end
end
@@ -0,0 +1,39 @@
class RemoveAllForeignKeyConstraints < ActiveRecord::Migration[8.2]
def change
remove_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" rescue nil
remove_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" rescue nil
remove_foreign_key "board_publications", "boards" rescue nil
remove_foreign_key "card_activity_spikes", "cards" rescue nil
remove_foreign_key "card_goldnesses", "cards" rescue nil
remove_foreign_key "card_not_nows", "cards" rescue nil
remove_foreign_key "card_not_nows", "users" rescue nil
remove_foreign_key "cards", "columns" rescue nil
remove_foreign_key "closures", "cards" rescue nil
remove_foreign_key "closures", "users" rescue nil
remove_foreign_key "columns", "boards" rescue nil
remove_foreign_key "comments", "cards" rescue nil
remove_foreign_key "events", "boards" rescue nil
remove_foreign_key "magic_links", "identities" rescue nil
remove_foreign_key "mentions", "users", column: "mentionee_id" rescue nil
remove_foreign_key "mentions", "users", column: "mentioner_id" rescue nil
remove_foreign_key "notification_bundles", "users" rescue nil
remove_foreign_key "notifications", "users" rescue nil
remove_foreign_key "notifications", "users", column: "creator_id" rescue nil
remove_foreign_key "pins", "cards" rescue nil
remove_foreign_key "pins", "users" rescue nil
remove_foreign_key "push_subscriptions", "users" rescue nil
remove_foreign_key "search_queries", "users" rescue nil
remove_foreign_key "sessions", "identities" rescue nil
remove_foreign_key "steps", "cards" rescue nil
remove_foreign_key "taggings", "cards" rescue nil
remove_foreign_key "taggings", "tags" rescue nil
remove_foreign_key "user_settings", "users" rescue nil
remove_foreign_key "users", "identities" rescue nil
remove_foreign_key "watches", "cards" rescue nil
remove_foreign_key "watches", "users" rescue nil
remove_foreign_key "webhook_delinquency_trackers", "webhooks" rescue nil
remove_foreign_key "webhook_deliveries", "events" rescue nil
remove_foreign_key "webhook_deliveries", "webhooks" rescue nil
remove_foreign_key "webhooks", "boards" rescue nil
end
end
@@ -0,0 +1,17 @@
class AddUniqueIndexToCardActivitySpikesOnCardId < ActiveRecord::Migration[8.2]
def change
reversible do |dir|
dir.up do
execute <<-SQL
DELETE s1 FROM card_activity_spikes s1
INNER JOIN card_activity_spikes s2
WHERE s1.card_id = s2.card_id
AND s1.updated_at < s2.updated_at
SQL
end
end
remove_index :card_activity_spikes, :card_id
add_index :card_activity_spikes, :card_id, unique: true
end
end
Generated
+2 -38
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_17_202517) do
ActiveRecord::Schema[8.2].define(version: 2025_11_20_203100) 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
@@ -150,7 +150,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_17_202517) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_card_activity_spikes_on_account_id"
t.index ["card_id"], name: "index_card_activity_spikes_on_card_id"
t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true
end
create_table "card_engagements", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -748,40 +748,4 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_17_202517) do
t.index ["account_id"], name: "index_webhooks_on_account_id"
t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 }
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "board_publications", "boards"
add_foreign_key "card_activity_spikes", "cards"
add_foreign_key "card_goldnesses", "cards"
add_foreign_key "card_not_nows", "cards"
add_foreign_key "card_not_nows", "users"
add_foreign_key "cards", "columns"
add_foreign_key "closures", "cards"
add_foreign_key "closures", "users"
add_foreign_key "columns", "boards"
add_foreign_key "comments", "cards"
add_foreign_key "events", "boards"
add_foreign_key "magic_links", "identities"
add_foreign_key "mentions", "users", column: "mentionee_id"
add_foreign_key "mentions", "users", column: "mentioner_id"
add_foreign_key "notification_bundles", "users"
add_foreign_key "notifications", "users"
add_foreign_key "notifications", "users", column: "creator_id"
add_foreign_key "pins", "cards"
add_foreign_key "pins", "users"
add_foreign_key "push_subscriptions", "users"
add_foreign_key "search_queries", "users"
add_foreign_key "sessions", "identities"
add_foreign_key "steps", "cards"
add_foreign_key "taggings", "cards"
add_foreign_key "taggings", "tags"
add_foreign_key "user_settings", "users"
add_foreign_key "users", "identities"
add_foreign_key "watches", "cards"
add_foreign_key "watches", "users"
add_foreign_key "webhook_delinquency_trackers", "webhooks"
add_foreign_key "webhook_deliveries", "events"
add_foreign_key "webhook_deliveries", "webhooks"
add_foreign_key "webhooks", "boards"
end
+261 -16
View File
@@ -1,33 +1,278 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require "pathname"
require "uri"
require "base64"
require "json"
ActionText::RichText.all.where("body LIKE '%/rails/active_storage/%'").find_each do |rich_text|
next unless rich_text.body
class FixActiveStorage
attr_reader :skipped, :processed, :scope
blobs = rich_text.embeds.map(&:blob)
def initialize(scope = nil)
@scope = scope || ActionText::RichText.all.where("body LIKE '%/rails/active_storage/%'")
@mapping = {}
rich_text.body.send(:attachment_nodes).each do |node|
url = node["url"]
next unless url
@skipped = 0
@processed = 0
url_encoded_filename = url.split("/").last
filename = URI.decode_www_form_component(url_encoded_filename)
@users = {}
@memberships = {}
@attachments = {}
@identities = {}
end
counter += 1
blob = blobs.select { |b| b.filename == filename }
raise "Multiple blobs with filename #{filename}" if blob.size > 1
blob = blob.first
def ingest_blob_keys(db_path)
models = Models.new(db_path)
if blob
node["sgid"] = blob.attachable_sgid
@mapping[models.accounts.sole.external_account_id.to_s] = models.blobs.all.index_by(&:id)
@attachments[models.accounts.sole.external_account_id.to_s] = models.attachments.all.index_by(&:id)
@users[models.accounts.sole.external_account_id.to_s] = models.users.all.index_by(&:id)
end
def ingest_untenanted(untenanted_db_path)
untenanted = Models.new(untenanted_db_path)
@memberships = untenanted.memberships.all.index_by(&:id)
@identities = untenanted.identities.all.index_by(&:id)
end
def perform
fix_avatars
fix_mentions
fix_attachments
pp [ @processed, @skipped ]
end
private
def fix_avatars
User.all.active.preload(:identity).find_each do |user|
tenant = user.account.external_account_id.to_s
email_address = user.identity.email_address
membership = @memberships.values.find { |m| m.tenant == tenant && @identities[m.identity_id]&.email_address == email_address }
old_user = @users[tenant]&.values&.find { |u| u.membership_id == membership&.id }
next if user.avatar.attached? || old_user.nil?
old_avatar_attachment = @attachments[tenant]&.values&.find do |attachment|
attachment.record_type == "User" && attachment.record_id == old_user.id && attachment.name == "avatar"
end
if old_avatar_attachment.nil?
@skipped += 1
next
end
old_blob = old_avatar_attachment.blob
if old_blob.nil?
@skipped += 1
next
end
new_blob = ActiveStorage::Blob.find_by(key: old_blob.key)
unless new_blob
new_blob = ActiveStorage::Blob.create!(
account_id: user.account_id,
byte_size: old_blob.byte_size,
checksum: old_blob.checksum,
content_type: old_blob.content_type,
created_at: old_blob.created_at,
filename: old_blob.filename,
key: old_blob.key,
metadata: old_blob.metadata,
service_name: old_blob.service_name
)
end
ActiveStorage::Attachment.find_or_create_by!(
account_id: user.account_id,
blob_id: new_blob.id,
name: "avatar",
record: user
)
@processed += 1
end
end
def fix_mentions
ActionText::RichText.where("body LIKE '%action-text-attachment%'").find_each do |rich_text|
rich_text.body.send(:attachment_nodes).each do |node|
next unless node["content-type"] == "application/vnd.actiontext.mention"
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
user = @users.dig(sgid.params[:tenant], sgid.model_id.to_i)
membership = @memberships[user&.membership_id]
unless membership
@skipped += 1
next
end
identity = @identities[membership&.identity_id]
unless identity
@skipped += 1
next
end
new_identity = Identity.find_by(email_address: identity.email_address)
new_account = Account.find_by(external_account_id: sgid.params[:tenant])
new_user = User.find_by(identity: new_identity, account: new_account)
new_sgid = new_user.attachable_sgid
node["sgid"] = new_sgid.to_s
end
rich_text.save!
end
end
def fix_attachments
scope.find_each do |rich_text|
next unless rich_text.body
rich_text.body.send(:attachment_nodes).each do |node|
sgid = node["sgid"]
url = node["url"]
next if url.blank? || sgid.blank?
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
old_blob = @mapping.dig(sgid.params[:tenant], sgid.model_id.to_i)
# There are some old files that got lost in a previous migration
unless old_blob
@skipped += 1
next
end
new_blob = ActiveStorage::Blob.find_by(key: old_blob.key)
unless new_blob
new_blob = ActiveStorage::Blob.create!(
account_id: rich_text.account_id,
byte_size: old_blob.byte_size,
checksum: old_blob.checksum,
content_type: old_blob.content_type,
created_at: old_blob.created_at,
filename: old_blob.filename,
key: old_blob.key,
metadata: old_blob.metadata,
service_name: old_blob.service_name
)
ActiveStorage::Attachment.create!(
account_id: rich_text.account_id,
blob_id: new_blob.id,
created_at: old_blob.created_at,
name: "embeds",
record: rich_text
)
end
node["sgid"] = new_blob.attachable_sgid
@processed += 1
end
rich_text.save!
rescue ActiveStorage::FileNotFoundError
@skipped += 1
next
end
end
end
class Models
attr_reader :application_record
def initialize(db_path)
const_name = "ImportBase#{db_path.hash.abs}"
if self.class.const_defined?(const_name)
@application_record = self.class.const_get(const_name)
else
skipped += 1
@application_record = Class.new(ActiveRecord::Base) do
self.abstract_class = true
def self.models
const_get("MODELS")
end
delegate :models, to: :class
end
self.class.const_set(const_name, @application_record)
end
@application_record.establish_connection adapter: "sqlite3", database: db_path
@application_record.const_set("MODELS", self)
end
def accounts
@accounts ||= Class.new(application_record) do
self.table_name = "accounts"
end
end
rich_text.save!
def blobs
models = self
@blobs ||= Class.new(application_record) do
self.table_name = "active_storage_blobs"
def attachments
models.attachments.where(blob_id: id)
end
end
end
def attachments
models = self
@attachments ||= Class.new(application_record) do
self.table_name = "active_storage_attachments"
def blob
models.blobs.find_by(id: blob_id)
end
end
end
def users
@users ||= Class.new(application_record) do
self.table_name = "users"
end
end
def identities
@identities ||= Class.new(application_record) do
self.table_name = "identities"
end
end
def memberships
@memberships ||= Class.new(application_record) do
self.table_name = "memberships"
end
end
end
# tenanted_db_paths = ARGV
tenanted_db_paths = Dir[Rails.root.join("storage/tenants/production/*/db/main.sqlite3")]
untenanted_db_path = Rails.root.join("storage/untenanted/production.sqlite3")
if tenanted_db_paths.empty?
$stderr.puts "Error: at least one tenanted database path is required"
$stderr.puts
exit 1
end
fix = FixActiveStorage.new
fix.ingest_untenanted(untenanted_db_path)
tenanted_db_paths.each_with_index do |db_path, _index|
fix.ingest_blob_keys(db_path)
end
fix.perform
@@ -24,7 +24,8 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
get join_path(code: @join_code.code, script_name: @account.slug)
assert_response :not_found
assert_response :gone
assert_in_body "This join code has no invitations left on it"
end
test "create" do
+13 -3
View File
@@ -5,9 +5,11 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
setup do
@board.update!(all_access: true)
@card = @board.cards.create!(title: "Layout is broken", creator: @user)
@card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", creator: @user)
@comment_card = @board.cards.create!(title: "Some card", creator: @user)
@comment_card.comments.create!(body: "overflowing text issue", creator: @user)
@comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", creator: @user)
@comment2_card.comments.create!(body: "I love haggis", creator: @user)
untenanted { sign_in_as @user }
end
@@ -15,11 +17,19 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
test "search" do
# Searching by card title
get search_path(q: "broken", script_name: "/#{@account.external_account_id}")
assert_select "li", text: /Layout is broken/
assert_select "li .search__title", text: /Layout is broken/
assert_select "li .search__excerpt", text: /Look at this mess/
# Searching by comment
get search_path(q: "overflowing", script_name: "/#{@account.external_account_id}")
assert_select "li", text: /Some card/
assert_select "li .search__title", text: /Some card/
assert_select "li .search__excerpt--comment", text: /overflowing text issue/
# Searching for a term that appears in a card and in a comment
get search_path(q: "haggis", script_name: "/#{@account.external_account_id}")
assert_select "li .search__title", text: /Just haggis/, count: 2 # card title shows up in two entries
assert_select "li .search__excerpt", text: /More haggis/ # one entry for the card description
assert_select "li .search__excerpt--comment", text: /I love haggis/ # one entry for the comment
# Searching by card id
get search_path(q: @card.id, script_name: "/#{@account.external_account_id}")
+2 -2
View File
@@ -18,13 +18,13 @@ class HotkeysHelperTest < ActionView::TestCase
test "mac enter" do
emulate_mac
assert_equal "+J", hotkey_label([ "enter", "J" ])
assert_equal "Return+J", hotkey_label([ "enter", "J" ])
end
test "linux enter" do
emulate_linux
assert_equal "+J", hotkey_label([ "enter", "J" ])
assert_equal "Enter+J", hotkey_label([ "enter", "J" ])
end
test "mac hyper" do
+1 -1
View File
@@ -10,7 +10,7 @@ class MagicLinkMailerTest < ActionMailer::TestCase
end
assert_equal [ "kevin@37signals.com" ], email.to
assert_equal "Your Fizzy verification code", email.subject
assert_equal "Your Fizzy code is #{ magic_link.code }", email.subject
assert_match magic_link.code, email.body.encoded
end
end
@@ -1,4 +1,4 @@
class MagicLinkPreview < ActionMailer::Preview
class MagicLinkMailerPreview < ActionMailer::Preview
def magic_link
identity = Identity.new email_address: "test@example.com"
magic_link = MagicLink.new(identity: identity)
@@ -1,6 +1,7 @@
class Notification::BundleMailerPreview < ActionMailer::Preview
def notification
ApplicationRecord.current_tenant = "897362094"
Notification::BundleMailer.notification Notification::Bundle.take!
bundle = Notification::Bundle.all.sample
Current.account = bundle.account
Notification::BundleMailer.notification bundle
end
end
+2 -1
View File
@@ -1,8 +1,9 @@
class UserMailerPreview < ActionMailer::Preview
def email_change_confirmation
new_email_address = "new.email@example.com"
user = User.all.sample
user = User.active.sample
token = user.send(:generate_email_address_change_token, to: new_email_address)
Current.account = user.account
UserMailer.email_change_confirmation(
email_address: new_email_address,
@@ -39,6 +39,21 @@ class Card::ActivitySpike::DetectorTest < ActiveSupport::TestCase
assert @card.reload.activity_spike.updated_at > original_last_spike_at
end
test "concurrent spike creation should not create multiple spikes for a card" do
multiple_people_comment_on(@card)
@card.activity_spike&.destroy
5.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
Card.find(@card.id).detect_activity_spikes
end
end
end.each(&:join)
assert_equal 1, Card::ActivitySpike.where(card: @card).count
end
private
def assert_activity_spike_detected(card: @card)
assert card.activity_spike.blank?
+1 -1
View File
@@ -65,6 +65,6 @@ class Card::StatusesTest < ActiveSupport::TestCase
card.publish
assert_equal Time.now, card.created_at
assert_equal Time.current, card.created_at
end
end
+19
View File
@@ -0,0 +1,19 @@
require "test_helper"
class Filter::SearchTest < ActiveSupport::TestCase
include SearchTestHelper
test "deduplicate multiple results" do
user = users(:david)
board = boards(:writebook)
card = board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: user)
card.published!
card.comments.create(body: "I hate haggis.", creator: user)
card.comments.create(body: "I love haggis.", creator: user)
filter = user.filters.new(terms: [ "haggis" ], indexed_by: "all", sorted_by: "latest")
assert_equal [ card ], filter.cards.to_a
end
end
+6 -10
View File
@@ -6,13 +6,11 @@ class FilterTest < ActiveSupport::TestCase
end
test "cards" do
Current.set session: sessions(:david) do
@new_board = Board.create! name: "Inaccessible Board", creator: users(:david)
@new_card = @new_board.cards.create!(status: "published")
@new_board = Board.create! name: "Inaccessible Board", creator: users(:david)
@new_card = @new_board.cards.create!(status: "published")
cards(:layout).comments.create!(body: "I hate haggis")
cards(:logo).comments.create!(body: "I love haggis")
end
cards(:layout).comments.create!(body: "I hate haggis")
cards(:logo).comments.create!(body: "I love haggis")
assert_not_includes users(:kevin).filters.new.cards, @new_card
@@ -93,10 +91,8 @@ class FilterTest < ActiveSupport::TestCase
end
assert_nil Filter.find(filter.id).as_params[:tag_ids]
Current.set session: sessions(:david) do
assert_changes "Filter.exists?(filter.id)" do
boards(:writebook).destroy!
end
assert_changes "Filter.exists?(filter.id)" do
boards(:writebook).destroy!
end
end