Merge branch 'main' into expand-activity-columns
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 don’t 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
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,4 +249,14 @@
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* Edge */
|
||||
scrollbar-width: none; /* FF */
|
||||
|
||||
/* Chrome/Safari/Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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)) +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,7 +5,7 @@ module HotkeysHelper
|
||||
if key == "ctrl" && platform.mac?
|
||||
"⌘"
|
||||
elsif key == "enter"
|
||||
"⏎"
|
||||
platform.mac? ? "return" : "enter"
|
||||
elsif key == "shift"
|
||||
"⇧"
|
||||
else
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ class Filter < ApplicationRecord
|
||||
result.mentioning(term, user: creator)
|
||||
end
|
||||
|
||||
result
|
||||
result.distinct
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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">←</kbd>
|
||||
</span>
|
||||
|
||||
@@ -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">←</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 %>
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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| %>
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -0,0 +1,5 @@
|
||||
<%= collapsible_nav_section "Tags" do %>
|
||||
<% tags.each do |tag| %>
|
||||
<%= my_menu_tag_item(tag) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,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">←</kbd>
|
||||
</span>
|
||||
|
||||
@@ -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">←</kbd>
|
||||
</span>
|
||||
|
||||
@@ -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">←</kbd>
|
||||
</span>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ module FizzyActiveJobExtensions
|
||||
|
||||
prepended do
|
||||
attr_reader :account
|
||||
self.enqueue_after_transaction_commit = true
|
||||
end
|
||||
|
||||
def initialize(...)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Regular → Executable
+261
-16
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user